Benutzerdefinierter HTML-Knoten
Wenn keine Kombination aus shape, color, icon und text ausreicht, geben Sie dem Renderer etwas Markup. html ist ein gewöhnlicher Stilkanal, daher wird eine Karte durch die gleiche Prioritätskette wie jede andere aufgelöst – was bedeutet, dass eine Karte etwas ist, das Sie an eine Art von Knoten geben, nicht an den gesamten Graphen.
Jeder Knoten unten kommt aus einem nodeStyleMap:
| Knoten | Deklariert als | Was Sie bekommen |
|---|---|---|
| die drei Teamkarten | shape: 'none' + html | Die Karte ist der Knoten – keine Form dahinter, und ihr gemessener Rahmen bestimmt das Layout und die Kanten |
| Auf Abruf | shape: 'circle' + html | Die Scheibe ist immer noch gezeichnet und size ist immer noch der Boden – eine Pille, die auf einer Form reitet |
staging · prod | nein html | einfache Sechsecke aus derselben Karte |
| Q3 einfrieren | shape: 'none', nein html | ein bloßes Etikett: nichts gezeichnet außer dem Text, immer noch ziehbar und auswählbar |
| Operationen | das eigene style.html des Knotens | Ein Knoten überschreibt die Karte seines Typs |
| Ingenieurwesen | render.renderNode | ein globaler Rückruf, der nur diesen Knoten kartiert und für den Rest nichts zurückgibt |
Vier Dinge, die es wert sind, provoziert zu werden:
- Klicken Sie auf eine Karte. Sie wird wie jeder andere Knoten hervorgehoben. Unter einem Nur-Karten-Knoten befindet sich genau darunter ein unsichtbares Kästchen, damit die ausgewählten und schwebenden Looks auf etwas landen können – ohne dieses Kästchen hatte ein benutzerdefinierter Knoten überhaupt keinen ausgewählten Status.
- Ziehen Sie die Karten herum. Die Kollisionskraft verteilt die echten Kartenkästen auf Abstand, und die Kanten enden am Rand jeder Karte und nicht an einem um sie herum gezogenen Kreis. Dafür musste am Layout nichts angepasst werden.
- Vergleiche
OperationsmitOn call. Beide sindhtml. Nur einer hat eine Form dahinter, und das ist das Ganze, wasshape: 'none'bewirkt. - Sehen Sie sich das Markup
Operationsan. Seine Wurzel istwidth: 100%– die Form, die früher auf das Platzhalterfeld und den Clip reduziert wurde. Die Karten werden in einer Schrumpfbox vermessen, sodass sie anhand ihres eigenen Inhalts verrechnet werden und Sie nichts tun müssen.
Nur vertrauenswürdiges Markup
Was auch immer Sie zurückgeben, wird unverändert eingefügt, sodass diese Karten mit textContent erstellt werden und nicht durch Interpolation von Knotendaten in eine Markup-Zeichenfolge. Siehe Sicherheit.
/** A team card. Built with `textContent`, never by interpolating data into markup. */
function teamCard(node) {
const d = node.getData()
const card = document.createElement('div')
card.setAttribute('style', [
'display: flex',
'align-items: center',
'gap: 8px',
'padding: 8px 12px',
'border-radius: 12px',
'background: #ffffff',
`border: 2px solid ${d.tone}`,
'box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18)',
'font-family: system-ui, sans-serif',
'white-space: nowrap',
].join(';'))
const emoji = document.createElement('span')
emoji.style.fontSize = '20px'
emoji.textContent = d.emoji
const stack = document.createElement('span')
stack.setAttribute('style', 'display: flex; flex-direction: column; line-height: 1.2')
const title = document.createElement('strong')
title.setAttribute('style', 'font-size: 13px; color: #0f172a')
title.textContent = d.name
const lead = document.createElement('span')
lead.setAttribute('style', `font-size: 11px; color: ${d.tone}`)
lead.textContent = d.lead
stack.append(title, lead)
card.append(emoji, stack)
return card
}
/** A small pill, drawn on top of the shape it is given rather than replacing it. */
function rotaPill(node) {
const pill = document.createElement('div')
pill.setAttribute('style', [
'display: inline-flex',
'padding: 2px 8px',
'border-radius: 999px',
'background: #0f172a',
'color: #fff',
'font: 600 10px system-ui, sans-serif',
'white-space: nowrap',
].join(';'))
pill.textContent = node.getData().lead
return pill
}
const options = {
render: {
nodeTypeAccessor: (node) => node.getData()?.kind,
nodeStyleMap: {
// A card and nothing else. `shape: 'none'` is what makes it the whole node:
// no disc behind it, and the node's radius and edge anchors come from the
// measured card instead of from `size`.
team: {
shape: 'none',
html: teamCard,
// The card carries its own title, so switch the SVG label off. `undefined`
// would fall through to whatever the chain said; `''` is how you clear it.
text: '',
},
// The other way round: the shape is still drawn, and `size` is still the
// smallest the node may be, so the pill rides on a coloured disc.
rota: {
shape: 'circle',
size: 30,
color: '#f59e0b',
html: rotaPill,
text: (node) => node.getData()?.name,
textVerticalShift: -1.5,
},
// No card at all — ordinary shapes, from the same style map.
env: {
shape: 'hexagon',
size: 22,
color: '#16a34a',
text: (node) => node.getData()?.name,
textVerticalShift: -1.4,
},
// Shapeless with no card either: a label floating on the canvas. Still
// draggable and selectable, over `2 × size`.
milestone: {
shape: 'none',
size: 26,
text: (node) => node.getData()?.name,
// Nothing behind it to shorten against, so draw it whole.
textTruncate: false,
},
},
// A global callback can card *some* nodes now: returning nothing hands the node
// back to the chain above, so everything else is styled as usual. Here it draws
// the badge on whichever team owns the release.
renderNode: (node) => {
if (node.id !== 'eng') return undefined
const card = teamCard(node)
card.style.borderWidth = '3px'
const flag = document.createElement('span')
flag.setAttribute('style', [
'margin-left: 8px',
'padding: 2px 6px',
'border-radius: 6px',
'background: #fee2e2',
'color: #b91c1c',
'font: 700 10px system-ui, sans-serif',
].join(';'))
flag.textContent = 'RELEASE OWNER'
card.append(flag)
return card
},
defaultEdgeStyle: { markerEnd: 'arrow', strokeWidth: 1.5 },
},
simulation: {
d3LinkDistance: 110,
d3ManyBodyStrength: -700,
d3CollideRadiusMultiplier: 1.15,
},
UI: {
mode: 'full',
minimap: false,
table: false,
// This card is about the nodes, so nothing else competes for the canvas.
legend: false,
},
}const data = {
nodes: [
// Cards, from a `nodeStyleMap` entry.
{ id: 'design', data: { kind: 'team', name: 'Design', lead: 'Lena', emoji: '🎨', tone: '#8b5cf6' } },
{ id: 'eng', data: { kind: 'team', name: 'Engineering', lead: 'Raj', emoji: '⚙️', tone: '#2563eb' } },
{ id: 'data', data: { kind: 'team', name: 'Data', lead: 'Omar', emoji: '📊', tone: '#0891b2' } },
// A card on a shape: the disc is still drawn behind it.
{ id: 'oncall', data: { kind: 'rota', name: 'On call', lead: 'Maya' } },
// Plain shapes, from the same style map.
{ id: 'staging', data: { kind: 'env', name: 'staging' } },
{ id: 'prod', data: { kind: 'env', name: 'prod' } },
// A label and no shape at all.
{ id: 'q3', data: { kind: 'milestone', name: 'Q3 freeze' } },
// One node overriding its type's card with its own.
{
id: 'ops', data: { kind: 'team', name: 'Operations', lead: 'Theo', emoji: '🧭', tone: '#ea580c' },
style: {
shape: 'none',
html: (node) => {
const d = node.getData()
const card = document.createElement('div')
// `width: 100%` and it still measures its content: the card is
// measured inside a shrink-to-fit box, not the placeholder.
card.setAttribute('style', [
'display: flex',
'align-items: center',
'gap: 8px',
'width: 100%',
'padding: 10px 14px',
'border-radius: 999px',
`background: ${d.tone}`,
'color: #fff',
'font: 600 13px system-ui, sans-serif',
'white-space: nowrap',
].join(';'))
card.textContent = `${d.emoji} ${d.name} — always on`
return card
},
},
},
],
edges: [
{ from: 'design', to: 'eng' },
{ from: 'eng', to: 'data' },
{ from: 'ops', to: 'eng' },
{ from: 'oncall', to: 'ops' },
{ from: 'eng', to: 'staging' },
{ from: 'staging', to: 'prod' },
{ from: 'q3', to: 'prod' },
{ from: 'data', to: 'prod' },
{ from: 'design', to: 'ops' },
],
}Siehe Rendering → HTML Knoten für den vollständigen Vertrag – wenn die Karte gemessen wird, was eine Karte tut: nicht Ausschalten und nur einige Knoten kardieren.