Detalle que sigue al zoom
Un nodo, cuatro dibujos. tiers elige entre ellos según el tamaño actual del nodo renders, por lo que una descripción general son puntos y un primer plano son tarjetas, y ninguno tiene que ser el compromiso con el que el otro puede vivir.
| Dibujo | Declarado como | aparece |
|---|---|---|
| un punto desnudo | el estilo básico | cuando incluso 32 píxeles es demasiado |
| un punto de color | tiers[0], 32×32 | una vez que el nodo tenga 32 píxeles de ancho |
| un chip etiquetado | tiers[1], 110×46 | una vez que representa 110, es decir, medio zoom |
| una tarjeta de resumen | tiers[2], 220×110 | una vez que representa 220, que es el zoom 1 aquí |
| una tarjeta de detalle | focusTier, 280×150 | al pasar el mouse o una selección solitaria, con cualquier zoom |
| la tarjeta de resumen otra vez | un indicador focusTier | al pasar el mouse, hasta que tiers[2] ya lo muestre |
tiers es una matriz y nada limita su longitud. El más ancho marca la huella, por lo que es el que se activa en el zoom 1 y se dibuja exactamente en su tamaño de diseño.
Cosas que vale la pena provocar:
- Desplácese hacia afuera y luego hacia adentro. Todo el lienzo cambia de dibujo a la vez, porque cada El nodo comparte una huella y por lo tanto un umbral. Nada se mueve mientras sucede: el El cuadro que cada nodo reserva en el diseño es el del nivel más ancho y el dibujo cambia dentro. eso.
- Desplácese lentamente a través de un umbral. Los dibujos se desvanecen en lugar de cortarse, por lo que nada parpadea: el saliente se mantiene en la pantalla mientras el nuevo aparece debajo eso.
render.detailTransitionestablece cuánto tiempo lleva y0retrasa el corte duro. - Estacione el zoom justo en un umbral y empújelo. Un nivel que se ha comprometido mantiene hasta que el tamaño renderizado cae al 85% de lo que lo involucró, por lo que la imagen se estabiliza de parpadeo.
- Observa el chip al entrar. Un nivel intermedio se activa en
width / footprintde zoom 1, por lo que parece más pequeño que la caja para la que fue diseñado y crece dentro de ella. Eso es el oficio de reducir uno: un dibujo más pequeño pide menos espacio y por eso aparece antes, pero también aparece más pequeño. Su tipo tiene un tamaño en unidades de gráfico para los zooms que realmente vive en; dale unminRenderedSizeexplícito si prefieres que espere más espacio sin volver a dibujarlo. - Pase el cursor sobre un punto mientras se aleja. La tarjeta de detalles tiene una escala contraria al zoom, por lo que son los mismos 280×150 píxeles por pequeño que sea el nodo. Leer un nodo nunca significa acercándose a él.
- Pase el cursor sobre la IP, el archivo o el correo electrónico, alejándolo y luego en el zoom 1. Su enfoque El sorteo es la tarjeta de resumen, que
focusTierYieldsAt: 2se retira una vez que el nivel de resumen lo dibuja de todos modos, por lo que acercarlos no agrega nada. La campaña aún abre su tarjeta de detalle. - Seleccione un nodo, luego presione Mayús y seleccione un segundo. Una selección promueve; dos no lo hacen. veinte Las cartas a la vez serían una pared.
La huella se declara, no se mide
width y height son números que usted proporciona, porque el diseño debe saber cuánto espacio salga antes del primer tic, y una tarjeta medida solo informa su tamaño después de haber sido dibujado.
/** 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' },
],
}Consulte Renderizado → Detalle que sigue al zoom para ver el contrato completo: qué elige un nivel, cómo mover un umbral y cuándo se activa el dibujo de enfoque.