Detail, das dem Zoom folgt
Ein Knoten, vier Zeichnungen. tiers wählt zwischen ihnen aus, je nachdem, wie groß der Knoten derzeit gerendert wird, also besteht eine Übersicht aus Punkten und eine Nahaufnahme aus Karten, und keiner muss der Kompromiss sein, mit dem der andere leben kann.
| Zeichnen | Deklariert als | Erscheint |
|---|---|---|
| ein bloßer Punkt | der Basisstil | wenn sogar 32 Pixel zu viel sind |
| ein farbiger Punkt | tiers[0], 32×32 | Sobald der Knoten 32 Pixel breit ist |
| ein beschrifteter Chip | tiers[1], 110×46 | Sobald es 110 rendert, also halber Zoom |
| eine Übersichtskarte | tiers[2], 220×110 | Sobald es 220 rendert, ist das hier Zoom 1 |
| eine Detailkarte | focusTier, 280×150 | beim Hover oder einer einzelnen Auswahl, bei jedem Zoom |
| noch einmal die Übersichtskarte | focusTier eines Indikators | beim Schweben, bis tiers[2] es bereits anzeigt |
tiers ist ein Array und nichts begrenzt seine Länge. Die breiteste Option legt den Footprint fest, sodass sie bei Zoom 1 aktiviert und genau in der Designgröße gezeichnet wird.
Dinge, die es wert sind, provoziert zu werden:
- Scrollen Sie raus und dann wieder hinein. Die gesamte Arbeitsfläche ändert ihre Zeichnung auf einmal, da jeder Knoten einen gemeinsamen Footprint und damit einen Schwellenwert hat. Dabei bewegt sich nichts: Die Box, die jeder Knoten im Layout reserviert, ist die breiteste Ebene, und die Zeichnung ändert sich darin.
- Scrollen Sie langsam über eine Schwelle. Die Zeichnungen werden überblendet, nicht geschnitten, sodass nichts blinkt: Die ausgehende Zeichnung bleibt auf dem Bildschirm, während die neue darunter erscheint.
render.detailTransitionlegt fest, wie lange das dauert, und0setzt den harten Schnitt zurück. - Parken Sie den Zoom direkt auf einer Schwelle und verschieben Sie ihn. Eine aktivierte Ebene bleibt bestehen, bis die gerenderte Größe auf 85 % der aktivierten Größe fällt, sodass sich das Bild stabilisiert, anstatt zu flackern.
- Beobachten Sie den Chip auf dem Weg hinein. Eine Zwischenebene rastet bei
width / footprintvon Zoom 1 ein, sodass sie kleiner erscheint als die Box, für die sie entworfen wurde, und hineinwächst. Das ist der Nachteil beim Einschränken: Eine kleinere Zeichnung benötigt weniger Platz und wird daher früher angezeigt, wirkt aber auch kleiner. Die Größe seines Typs wird in Grafikeinheiten für die Zooms angegeben, mit denen er tatsächlich arbeitet. Geben Sie ihm ein explizitesminRenderedSize, wenn Sie möchten, dass es lieber auf mehr Platz wartet, ohne es neu zu zeichnen. - Bewegen Sie den Mauszeiger über einen Punkt, während Sie herausgezoomt sind. Die Detailkarte wird im Verhältnis zum Zoom skaliert, sodass sie die gleichen 280×150 Pixel hat, egal wie klein der Knoten ist. Das Lesen eines Knotens bedeutet niemals, darauf zu zoomen.
- Bewegen Sie den Mauszeiger über die IP, die Datei oder die E-Mail, verkleinern Sie sie und dann auf Zoom 1. Ihre Fokuszeichnung ist die Zusammenfassungskarte, die
focusTierYieldsAt: 2in den Ruhestand geht, sobald die Zusammenfassungsebene sie ohnehin zeichnet, sodass das Bewegen des Mauszeigers aus der Nähe nichts hinzufügt. Die Kampagne öffnet weiterhin ihre Detailkarte. - Wählen Sie einen Knoten aus und wählen Sie dann bei gedrückter Umschalttaste einen zweiten aus. Eine Auswahl befördert; zwei nicht. Zwanzig Karten auf einmal wären eine Wand.
Der Fußabdruck wird deklariert, nicht gemessen
width und height sind Zahlen, die Sie angeben, da das Layout wissen muss, wie viel Platz vor dem ersten Tick gelassen werden muss – und eine gemessene Karte ihre Größe erst anzeigt, nachdem sie gezogen wurde.
/** 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 },
},
}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' },
],
}Den vollständigen Vertrag finden Sie unter Rendering → Detail, das dem Zoom folgt: Was wählt eine Ebene aus, wie verschiebt man einen Schwellenwert und wann wird die Fokuszeichnung ausgelöst?