缩放后的细节
一个节点,四张图。 tiers 根据节点当前渲染的大小在它们之间进行选择,因此概览是点,特写是卡片,两者都不必是对方可以忍受的妥协。
| 绘图 | 声明为 | 出现 |
|---|---|---|
| 一个裸点 | 基本风格 | 即使 32 像素也太多了 |
| 一个彩色点 | tiers[0], 32×32 | 一旦节点呈现 32 像素宽 |
| 贴有标签的芯片 | tiers[1], 110×46 | 一旦渲染为 110,即一半缩放 |
| 总结卡 | tiers[2], 220×110 | 一旦渲染为 220,即此处的缩放 1 |
| 详细信息卡 | focusTier, 280×150 | 悬停或单独选择时,在任意缩放下 |
| 再次总结卡 | 指标的focusTier | 悬停时,直到 tiers[2] 已经显示 |
tiers 是一个数组,没有任何东西限制其长度。最宽的一个设置了占地面积,因此它是在缩放 1 时接合的一个,并且完全按照其设计尺寸绘制。
值得挑衅的事情:
- 滚动出去,然后返回。整个画布立即改变绘图,因为每个节点共享一个足迹,因此有一个阈值。当它发生时,没有任何东西移动:每个节点在布局中保留的框是最宽的层,并且其中的绘图发生变化。
- 缓慢滚动穿过阈值。 绘图会交叉淡入淡出而不是剪切,因此不会有任何闪烁:传出的绘图保留在屏幕上,而新的绘图则出现在其下方。
render.detailTransition设置需要多长时间,0则设置硬削减。 - 将变焦停在阈值上并轻推它。已使用的层会一直保持,直到渲染大小降至其使用大小的 85%,因此图片会稳定下来而不是闪烁。
- 观察进入过程中的芯片。中间层与缩放1的
width / footprint接合,因此它看起来比设计的盒子要小,并且会长入其中。这就是缩小尺寸的交易:较小的绘图需要较少的空间,因此显示得更快,但它也显示得更小。其类型的大小以图形单位为单位,以适应其实际所处的缩放比例;如果您希望它等待更多空间而不重新绘制它,请给它一个显式的minRenderedSize。 - 缩小时将鼠标悬停在一个点上。 详细信息卡会根据缩放进行反缩放,因此无论节点有多小,它都是相同的 280×150 像素。读取一个节点绝不意味着缩放到它。
- 将鼠标悬停在 IP、文件或电子邮件上,缩小,然后缩放到 1。 它们的焦点绘图是摘要卡,一旦摘要层绘制它,
focusTierYieldsAt: 2就会退出,因此将它们悬停在附近不会添加任何内容。该活动仍然打开其详细信息卡。 - 选择一个节点,然后按住 Shift 键选择第二个节点。 一个选择会提升;两个不。一次二十张牌就会成为一堵墙。
足迹是声明的,而不是测量的
width 和 height 是您给出的数字,因为布局必须知道在第一个刻度之前留出多少空间 - 并且测量的卡片仅在绘制后报告其大小。
js
/** Shared shell for the three card drawings, so they differ only in what they carry. */
function shell(node, width, height) {
const d = node.getData()
const box = document.createElement('div')
box.setAttribute('style', [
'box-sizing: border-box',
`width: ${width}px`,
`height: ${height}px`,
'padding: 6px 10px',
'border-radius: 8px',
`border: 2px solid ${SEV[d.sev].tone}`,
`background: ${SEV[d.sev].wash}`,
'font-family: system-ui, sans-serif',
'overflow: hidden',
].join(';'))
return box
}
function row(label, value, tone, size = 11) {
const line = document.createElement('div')
line.setAttribute('style', `display: flex; justify-content: space-between; gap: 10px; font-size: ${size}px; line-height: 1.6`)
const left = document.createElement('span')
left.setAttribute('style', 'color: #64748b')
left.textContent = label
const right = document.createElement('span')
right.setAttribute('style', `color: ${tone}; font-weight: 600`)
right.textContent = value
line.append(left, right)
return line
}
// Every line states its own line-height. Left to `normal` the browser picks one from the
// font, and a box sized to fit on one machine clips its last line on another.
const LINE = 1.2
function title(text, size, weight = 600) {
const el = document.createElement('div')
el.setAttribute('style', `font: ${weight} ${size}px/${LINE} system-ui, sans-serif; color: #0f172a;`
+ 'white-space: nowrap; overflow: hidden; text-overflow: ellipsis')
el.textContent = text
return el
}
function kindLine(node, size, gap = 0) {
const el = document.createElement('div')
el.setAttribute('style', `font-size: ${size}px; line-height: ${LINE};`
+ `color: ${SEV[node.getData().sev].tone}; margin-bottom: ${gap}px`)
el.textContent = node.getData().kind
return el
}
/**
* The small card: a name and what kind of thing it is.
*
* 110 wide against the 220 footprint, so it takes over at half zoom — a narrower drawing
* asks for less room, and its threshold is its width.
*
* Its type is set larger than the bigger cards' below, which looks backwards until you
* notice these sizes are in graph units. This tier is only ever drawn between k = 0.5 and
* k = 1, so 18 units lands at 9-18 CSS pixels; the card below is never drawn under k = 1,
* where 14 units is already 14 pixels. Each drawing is sized for the zoom it lives at.
*/
function chip(node) {
const d = node.getData()
const box = shell(node, 110, 46)
box.style.padding = '5px 8px'
box.append(title(d.name, 18), kindLine(node, 11))
return box
}
/** The medium card: room for the two facts you sort on, without opening anything. */
function summaryCard(node) {
const d = node.getData()
const box = shell(node, 220, 110)
box.style.padding = '8px 12px'
box.append(title(d.name, 14, 700), kindLine(node, 11, 4),
row('Severity', d.sev, SEV[d.sev].tone, 12),
row('Sightings', String(d.count), '#0f172a', 12))
return box
}
/** The focus drawing: everything the node knows, at a size that is readable at any zoom. */
function detailCard(node) {
const d = node.getData()
const box = shell(node, 280, 150)
box.style.padding = '10px 14px'
box.append(title(d.name, 14, 700), kindLine(node, 11, 6),
row('Severity', d.sev, SEV[d.sev].tone),
row('First seen', d.first, '#0f172a'),
row('Sightings', String(d.count), '#0f172a'),
row('Source', 'internal feed', '#0f172a'))
return box
}
const INDICATORS = new Set(['IP', 'File', 'Email'])
const options = {
render: {
defaultNodeStyle: {
// The floor: what a node draws when no tier qualifies at all.
shape: 'circle',
size: 7,
color: (node) => SEV[node.getData().sev].tone,
// Ordered smallest first; the richest one that fits wins. There is no limit on
// how many there are — three here, and the base style above is the floor when
// none of them qualifies.
//
// Each tier's `width` is both its design box and, by default, the rendered size
// at which it takes over. The widest one sets the footprint, so it engages at
// exactly k = 1 and is drawn at its design size; the two narrower ones engage
// proportionally earlier, which is why their type is sized for the zoom they
// live at rather than for their box.
tiers: [
{
width: 32, height: 32,
style: { shape: 'circle', size: 16, color: (node) => SEV[node.getData().sev].tone },
},
{
width: 110, height: 46,
style: { shape: 'none', html: chip },
},
{
width: 220, height: 110,
style: { shape: 'none', html: summaryCard },
},
],
// Whatever the zoom, a hovered or singly-selected node draws this instead. It
// merges over the style above rather than over the active tier, and holds a
// constant size on screen.
focusTier: { shape: 'none', html: detailCard },
},
// A plain indicator has nothing beyond its summary to show, so its focus drawing is
// the summary card, and it steps aside once `tiers[2]` draws that card anyway.
nodeTypeAccessor: (node) => INDICATORS.has(node.getData().kind) ? 'indicator' : undefined,
nodeStyleMap: {
indicator: {
focusTier: { shape: 'none', html: summaryCard },
focusTierYieldsAt: 2,
},
},
},
simulation: {
// Tight enough that the graph opens on chips: the collision force already holds the
// footprints apart, so the links only have to stop it spreading further.
d3LinkDistance: 60,
},
UI: {
// The focus tier already draws everything a hover would say, so the tooltip beside
// it would only repeat itself. Both fire on hover; here only one should.
tooltip: { enabled: false },
},
}js
const SEV = {
high: { tone: '#dc2626', wash: '#fef2f2' },
medium: { tone: '#d97706', wash: '#fffbeb' },
low: { tone: '#0891b2', wash: '#ecfeff' },
}
const data = {
nodes: [
{ id: 'camp', data: { kind: 'Campaign', name: 'SILENT-QUAY', sev: 'high', first: '2026-02-11', count: 41 } },
{ id: 'actor', data: { kind: 'Threat actor', name: 'UNC-4417', sev: 'high', first: '2025-11-02', count: 63 } },
{ id: 'mal1', data: { kind: 'Malware', name: 'Dropbear', sev: 'high', first: '2026-03-04', count: 22 } },
{ id: 'c2a', data: { kind: 'Host', name: 'cdn-a.example', sev: 'high', first: '2026-03-06', count: 18 } },
{ id: 'ip1', data: { kind: 'IP', name: '203.0.113.44', sev: 'medium', first: '2026-03-09', count: 6 } },
{ id: 'hash', data: { kind: 'File', name: '9f2c…a10e', sev: 'high', first: '2026-03-04', count: 14 } },
{ id: 'mail', data: { kind: 'Email', name: 'ops@parcel.example', sev: 'low', first: '2026-02-27', count: 2 } },
{ id: 'vict', data: { kind: 'Sector', name: 'Maritime logistics', sev: 'low', first: '2026-02-14', count: 5 } },
],
edges: [
{ from: 'actor', to: 'camp' }, { from: 'camp', to: 'mal1' }, { from: 'camp', to: 'mail' },
{ from: 'camp', to: 'vict' }, { from: 'mal1', to: 'c2a' }, { from: 'mal1', to: 'hash' },
{ from: 'c2a', to: 'ip1' }, { from: 'actor', to: 'mal1' },
],
}请参阅渲染 → 缩放之后的详细信息以获取完整的合同:什么选择层、如何移动阈值以及焦点绘图何时触发。