Image fit modes
An imagePath node draws a picture, but how the picture sits on the shape is up to you — set imageFit (a value, or a (node) => … so different nodes can differ). Here one landscape screenshot is rendered three ways on identical squares, a fourth square whose source is missing (to show the fallback), and a 'frame' node below that takes the picture's own shape:
'icon'(default) — a small picture (~1.2×size) centred on the shape, aspect preserved. This is the legacy look; the coloured shape shows around it, so a picture reads as an icon alongside other icon-class nodes.'cover'— the picture fills the shape's box (2×size) and is cropped to preserve its aspect ratio. A clean filled thumbnail whose stroke reads as a thin border — the fit for screenshot/attachment nodes.'contain'— the whole picture fits inside the box, letterboxed; the shape'scolorshows in the bars for non-square images.'frame'— the node becomes the picture: it's sized to the image's own aspect ratio (longest side = 2×size), so the whole screenshot shows at full resolution with the stroke hugging it — no crop, no letterbox bars.shapeis ignored (the picture is the node). Ideal for screenshot/attachment previews.
When a picture's source can't be loaded, the node degrades to a crossed-out-picture placeholder instead of the browser's broken-image glyph — see the fourth square, whose imagePath points nowhere. The same graceful fallback appears everywhere the picture would otherwise show: the hover preview, the tooltip, and the full-resolution lightbox.
Because it resolves per node, one graph can mix modes — screenshots 'frame' or 'cover', other attachments 'icon' — straight off the node's data. Growing size scales the picture proportionally, so the relative border stays the same — no fixed inset.
const options = {
// Static legend: fx/fy pin the nodes, no forces so nothing drifts.
simulation: { enabled: false },
render: {
defaultNodeStyle: {
size: 44,
text: (node) => node.getData()?.name,
textVerticalShift: -1.6
}
}
}// A landscape raster shared by all nodes so the fit modes differ visibly
// (a square/vector image would make `cover` and `contain` look identical).
const pic = new URL('./sample.png', import.meta.url).href
// A sibling path with no file behind it, to show the missing-image fallback.
// Derived from `pic` at runtime so the build never tries to resolve a missing asset.
const missingPic = new URL('missing.png', pic).href
const data = {
nodes: [
{ id: 'icon', fx: -285, fy: 0, data: { name: "'icon'" },
style: { shape: 'square', imagePath: pic, imageFit: 'icon', color: '#f1f5f9' } },
{ id: 'cover', fx: -95, fy: 0, data: { name: "'cover'" },
style: { shape: 'square', imagePath: pic, imageFit: 'cover',
strokeColor: 'rgba(255,255,255,.55)', strokeWidth: 2 } },
{ id: 'contain', fx: 95, fy: 0, data: { name: "'contain'" },
style: { shape: 'square', imagePath: pic, imageFit: 'contain', color: '#1f2937',
strokeColor: 'rgba(255,255,255,.55)', strokeWidth: 2 } },
// Missing source: the picture can't load, so the node degrades to a
// crossed-out-picture placeholder instead of a broken-image glyph. The
// same fallback appears in the hover preview, tooltip and lightbox.
{ id: 'missing', fx: 285, fy: 0, data: { name: 'missing' },
style: { shape: 'square', imagePath: missingPic, imageFit: 'cover', color: '#1f2937',
strokeColor: 'rgba(255,255,255,.55)', strokeWidth: 2 } },
// 'frame': the node takes the picture's own aspect ratio, so the whole
// screenshot shows at full resolution with the stroke hugging it — no crop,
// no letterbox bars. Bigger `size` just makes the framed picture bigger.
{ id: 'frame', fx: 0, fy: 360, data: { name: "'frame'" },
style: { imagePath: pic, imageFit: 'frame', size: 176,
strokeColor: 'rgba(255,255,255,.55)', strokeWidth: 2 } }
],
edges: []
}