Rim badges
Colour, shape, size and icon are usually all spoken for by the time you want to show one more thing. badges is a channel of its own: small indicators pinned to the node's rim, returned per node, spending none of the encodings above.
Here the style map owns the node's type and the badges carry two independent facts on top — how many notes a node has, and whether anyone disputes it. Click a blue count to see a badge handle its own click; hover any badge for its tooltip.
Badges auto-place clockwise from the top-right into whichever corners are free, so you rarely name a position. Four fit on a plain node and two on one with children, since the expand affordance owns the East side; anything past that folds into a +n that names what it hid.
js
// Colour, shape and icon are already spent on the node's *type*, so the two extra facts —
// how many notes it carries and whether anyone disputes it — go on badges instead.
const badges = (node) => {
const { notes, state } = node.getData()
const list = []
if (notes) {
list.push({
text: String(notes),
color: '#2563eb',
title: `${notes} notes`,
onClick: () => window.alert(`Open the ${notes} notes on ${node.getData().name}`)
})
}
if (state === 'disputed') {
list.push({ text: '!', color: '#dc2626', title: 'Disputed' })
}
if (state === 'confirmed') {
list.push({ text: '✓', color: '#16a34a', title: 'Confirmed' })
}
return list
}
const options = {
render: {
nodeTypeAccessor: (node) => node.getData()?.type,
nodeStyleMap: {
case: { color: '#7c3aed', size: 30 },
host: { color: '#0891b2', shape: 'square', size: 24 },
address: { color: '#f59e0b', size: 22 },
file: { color: '#64748b', shape: 'hexagon', size: 22 }
},
defaultNodeStyle: {
badges,
strokeColor: '#ffffff',
strokeWidth: 2,
textColor: '#334155',
text: (node) => node.getData()?.name,
textVerticalShift: -1.7
}
},
simulation: {
// Badges add to a node's footprint, so the nodes need more room between them
// than the defaults give — otherwise a rim badge lands on its neighbour's label.
d3ManyBodyStrength: -420,
d3LinkDistance: 105
}
}js
const data = {
nodes: [
{ id: 'incident', data: { name: 'Incident', type: 'case', notes: 12, state: 'disputed' } },
{ id: 'host', data: { name: 'web-01', type: 'host', notes: 3 } },
{ id: 'ip', data: { name: '198.51.100.7', type: 'address', notes: 1, state: 'confirmed' } },
{ id: 'hash', data: { name: 'a94f…c2', type: 'file', state: 'disputed' } },
{ id: 'domain', data: { name: 'evil.example', type: 'address' } }
],
edges: [
{ from: 'incident', to: 'host' },
{ from: 'incident', to: 'hash' },
{ from: 'incident', to: 'ip' },
{ from: 'host', to: 'ip' },
{ from: 'ip', to: 'domain' },
{ from: 'hash', to: 'host' }
]
}