ズーム後の詳細
1 つのノードに 4 つの図面。 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% に低下するまで、代わりに画像が安定します。 ちらつきの。
- 途中でチップに注目してください。 中間層が
width / footprintで関与します。 ズーム 1 なので、設計されたボックスよりも小さく表示され、ボックス内に成長します。つまり 1 つを狭めるというトレード: 図面が小さいほど必要なスペースが少なくなるため、より早く表示されます。 しかし、それも小さく見えます。そのタイプは、実際にズームするためのグラフ単位でサイズ設定されます。 に住んでいます。もっとスペースを待ちたい場合は、明示的にminRenderedSizeを付けてください 描き直さずに。 - ズームアウト中に点にカーソルを合わせます。 詳細カードはズームに反して拡大縮小されるため、 ノードがどんなに小さくても、同じ 280×150 ピクセルです。 1 つのノードを読み取ることは意味がありません それにズームします。
- IP、ファイル、または電子メールにマウスを置き、ズームアウトしてからズーム 1 にします。 フォーカス 描画は概要カードであり、
focusTierYieldsAt: 2概要層が終了すると廃止されます。 とにかく描画するので、近くにホバーしても何も追加されません。キャンペーンはまだ始まっています 詳細カード。 - ** ノードを 1 つ選択し、Shift キーを押しながら 2 番目のノードを選択します。** 1 つ選択するとプロモートされます。 2つはそうではありません。 20 カードはすぐに壁になるでしょう。
フットプリントは測定されるものではなく宣言される
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' },
],
}完全なコントラクトについては、 レンダリング → ズーム に続く詳細を参照してください。 層を選択するもの、しきい値を移動する方法、フォーカス描画がいつ開始されるか。