Detail dat de zoom volgt
Eén knooppunt, vier tekeningen. tiers kiest tussen de hoe groot het knooppunt momenteel is wordt weergegeven, dus een overzicht bestaat uit stippen en een close-up uit kaarten, en dat hoeft ook niet zo te zijn compromis waar de ander mee kan leven.
| Tekening | Aangegeven als | Komt opdagen |
|---|---|---|
| een kale stip | de basisstijl | wanneer zelfs 32 pixels teveel is |
| een gekleurde stip | tiers[0], 32×32 | zodra het knooppunt 32 pixels breed wordt weergegeven |
| een gelabelde chip | tiers[1], 110×46 | zodra het 110 rendert, d.w.z. halve zoom |
| een overzichtskaart | tiers[2], 220×110 | zodra het 220 wordt weergegeven, wat hier zoom 1 is |
| een detailkaart | focusTier, 280×150 | bij zweven of een enkele selectie, bij elke zoomlens |
| de overzichtskaart opnieuw | de focusTier van een indicator | bij zweven, totdat tiers[2] het al laat zien |
tiers is een array en niets beperkt de lengte ervan. De breedste bepaalt de voetafdruk, en dat is ook zo degene die vastklikt op zoom 1 en precies op de ontwerpgrootte is getekend.
Dingen die het waard zijn om te provoceren:
- Scroll naar buiten en dan weer naar binnen. Het hele canvas verandert in één keer van tekening, omdat elke node deelt een voetafdruk en dus een drempel. Niets beweegt terwijl het gebeurt: de vak elk knooppunt reserveert in de lay-out is het breedste niveau, en de tekening verandert binnenin het.
- Scroll langzaam door een drempel. De tekeningen vervagen in plaats van te snijden, dus er knippert niets: de uitgaande wordt op het scherm gehouden terwijl de nieuwe eronder verschijnt het.
render.detailTransitionbepaalt hoe lang dat duurt, en0verlegt de harde bezuinigingen. - Parkeer de zoom precies op een drempel en duw deze aan. Een niveau dat is ingeschakeld, houdt vast totdat de weergegeven grootte daalt tot 85% van wat ermee bezig was, zodat het beeld in plaats daarvan tot rust komt van flikkeren.
- Bekijk de chip terwijl hij binnenkomt. Een tussenliggend niveau treedt in actie bij
width / footprintvan zoom 1, zodat hij kleiner lijkt dan de doos waarvoor hij is ontworpen en erin groeit. Dat is de handel in het verkleinen: een kleinere tekening vraagt minder ruimte en verschijnt dus eerder, maar hij blijkt ook kleiner. Het type heeft de grootte in grafische eenheden voor de zoomlenzen die het daadwerkelijk heeft woont in; geef het een explicieteminRenderedSizeals je liever hebt dat er op meer ruimte wordt gewacht zonder het opnieuw te tekenen. - Beweeg een punt terwijl u uitzoomt. De detailkaart wordt tegengesteld aan de zoom, zodat het zijn dezelfde 280×150 pixels, hoe klein het knooppunt ook is. Eén knooppunt lezen betekent nooit erop inzoomen.
- Beweeg over het IP-adres, het bestand of de e-mail, zoom uit en vervolgens op zoom 1. Hun focus tekening is de samenvattingskaart, die
focusTierYieldsAt: 2met pensioen gaat zodra de samenvattingslaag is bereikt tekent het toch, dus als u ze van dichtbij beweegt, voegt u niets toe. De campagne opent nog steeds zijn detailkaart. - Selecteer een knooppunt en selecteer vervolgens een tweede. Eén selectie bevordert; twee niet. Twintig kaarten tegelijk zouden een muur zijn.
De voetafdruk wordt aangegeven, niet gemeten
width en height zijn getallen die u opgeeft, omdat de lay-out moet weten hoeveel ruimte er moet zijn vertrek vóór de eerste tik - en een gemeten kaart geeft pas de grootte weer nadat deze is geweest getekend.
/** 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' },
],
}Zie Rendering → Detail dat volgt op de zoom voor het volledige contract: wat een niveau kiest, hoe een drempel moet worden verplaatst en wanneer de focustekening wordt geactiveerd.