Skip to content

UI/node shape - #10

Merged
pakdekro merged 20 commits into
mainfrom
ui/node-shape
Aug 28, 2026
Merged

UI/node shape#10
pakdekro merged 20 commits into
mainfrom
ui/node-shape

Conversation

@pakdekro

Copy link
Copy Markdown
Owner

Draws the graph, and turns the arrangements that existed because it could not be drawn into questions you ask of it.

The change underneath

Every edge used to leave the bottom of its source and enter the top of its target, because that is where the two handles were. On a graph drawn top to bottom that reads fine; on one the analyst has arranged, it is the whole of the spaghetti. An edge pointing up has to leave downwards, swing around the card and come back, and that detour carries no information.

The anchor now aims for the middle of the side facing the other object, and leaves that middle only when it has to: several edges on one side fan out around it, ordered the way their targets lie along it. What says which way a relationship runs is the arrowhead, which it always did.

What that made possible

A layout that draws the graph. A CTI investigation is a star, not a flow, which is why ranks failed: they draw a hub and seventeen spokes as a ribbon a screen and a half wide and 250px tall. A star does have a good drawing, and it needed the edges to radiate. Hub in the middle, what it touches on a ring around it, so distance from the centre means hops from the subject of the case. Measured rather than asserted, and the harness is committed with it (layoutCompare.test.ts):

shape dagre TB radial
Operation Aviary 1 crossing, 2830x1710 4 crossings, 3753x1750
pure star, 17 spokes 0, 4294x246 0, 1846x1018
star with tails 0, 4294x612 0, 2770x1568
big star, 41 objects 0, 8104x429 0, 3751x2134

Neither wins outright and that is the finding: ranks draw a chain better, and draw a star as something you cannot read.

Lenses instead of arrangements. The seven arrangements were built in a world where the graph could not be drawn, and made a virtue of ignoring the relationships. Once it could be drawn, ignoring them stopped being a precaution: the answer to "what has no indicator on it" is a set of objects, and piling that set into a block takes away the context that made those objects mean anything. Five questions now light what answers them and step the rest back, on whatever layout is on screen. By type and by ATT&CK tactic are gone rather than reworded: both were partitions, and the first told the analyst something every card already says in its own colour.

Reading the canvas

  • A colour per relationship family, not per verb. Twenty-six verbs and six more hues on top of the eighteen the objects carry would say "these differ" louder than they differ. Five questions instead: who is behind it, what it wields, who it hits, how it would be seen, where it lives.
  • A grip on every side of the card, hidden at rest, because a triangle at twelve o'clock claimed something that stopped being true.
  • The annotation diamond says whether there is anything to read. A note pinned beside an object is obvious; one left in the inspector was invisible until you clicked.
  • The labels in use, in their own panel, each one a lens. Compared exactly and never case-folded: they are free text and they drift, and this is the only place you see your own vocabulary at once.
  • A legend beside the minimap, for the codes the canvas carries and the sidebar does not already name.
  • t shows or hides the labels written on the cards.

Two canvas bugs found on the way

Both were making every arrangement look worse than it was. The fit ran one frame too early and fitted the previous layout - the same arrangement clicked twice gave two different viewports. And fitView({ minZoom }) never did anything: it changes the viewport being computed, then d3-zoom clamps the transform to the canvas's own scale extent, which was the default 0.5. So the fit stopped at half size and every arrangement ran off the sides.

Verification

542 tests, tsc clean.

Beyond the unit tests, a pass through the app as a user from a blank investigation turned up three defects, now fixed: the annotation grip drawn at rest on every card, the ring gap too small for a relationship's verb to fit between two objects, and three buttons at two different heights on the front page.

Then a review of the whole branch turned up six more, also fixed. The one with teeth: moving the edge colour from an inline style to a stylesheet variable had broken the image export, because html-to-image clones each edge's own without inlining what its descendants compute, so the strokes came out absent and the verbs black. A comment above toEdge had said exactly that, written the last time someone hit it; the comment survived the edit and the code it guarded did not. Colours are set on the element again, and the highlight with them, which as a side effect makes the link focus reach the LINE for the first time.

The other five: a row without properties could take the canvas down on every load; Escape cleared the lens while closing the search or a menu; any lens faded the whole annotation layer, including notes hanging off lit objects; two cards on the same spot lost their relationship entirely, undrawn and unclickable; and a bare t fired inside a focused .

Two causes were being read as one. The shape of the box is the smaller of
them; the bigger is that every edge left the bottom of its source and
entered the top of its target, because that is where the two handles are.
An edge pointing up therefore had to leave downwards, swing around the
node and come back. That detour carries no information and there is one
per edge, so the denser the graph the more of the drawing is detour.

Floating anchors drop the two fixed points: each end meets the outline of
its node along the line joining the two centres, so an edge leaves in the
direction it is actually going. The handles stay - they are what you drag
from to create a link - they simply no longer decide where the line goes.
This is shape-independent, which is the point: it fixes the card too.

On top of that, a shape setting, because the second cause is ink. A card
is 230x63 of text; sixty of them and the canvas is mostly writing, which
is exactly the ink that hides the structure. Compact keeps the name on one
line in a pill. Disc replaces the text with a three-letter type badge and
writes the name underneath it, at about a sixth of the ink.

The disc's box holds the badge AND its caption, so the arrangements give
the caption its own room, and the edge geometry is told the badge sits at
the top of that box rather than at its centre.

Test branch, not for merge as is.
The shapes were answering the wrong half of the question. Keeping the card
and moving only the ANCHOR is the change worth testing: what says which way
a relationship runs is the arrowhead, and it always did - where the line
touched the card was never carrying that, it was an artefact of the handles
sitting at the top and the bottom.

So the card stays a rectangle and nothing about it changes on screen. The
anchor now lives on an invisible oval a little outside it and slides around
that oval to face whatever it is joining.

The oval is a superellipse, not an ellipse: an ellipse that stays clear of a
230x63 card has to be half as wide again, which leaves the arrowheads
floating in space on the long sides. The exponent pulls the curve out
towards the corners so the outline keeps a near constant distance the whole
way round.
…owded

The oval was the honest geometric answer and it looked wrong. A card with a
single relationship met it at a corner, because that is where the ray
between the two centres happened to cross - a position that carries no
meaning and reads as a mistake.

So the anchor aims for the MIDDLE of the side facing the other object, and
leaves that middle only when it has to: several edges on the same side fan
out around it, ordered the way their targets lie along that side so they do
not cross each other in the last few pixels. The fan widens by a fixed
pitch until it would reach the corners, then tightens to fit rather than
spilling over them.

Which means the anchors cannot be worked out edge by edge any more: an edge
cannot know how many others are competing for the side it wants. They are
computed for the whole graph in one pass in the workspace and handed to the
edges, which is also why the notes and captures are left out of it - their
handle position IS the statement.
Every other arrangement ignores the relationships on purpose. This one does
not, which a layered layout failed at, for a reason worth restating: a CTI
investigation is a STAR, not a flow. Measured again here, on shapes this app
actually produces - the numbers and the harness are in layoutCompare.test.ts:

  Operation Aviary   dagre 1 crossing  2830x1710 | radial 4  3161x1404
  pure star (17)     dagre 0           4294x 246 | radial 0  1846x1018
  star with tails    dagre 0           4294x 612 | radial 0  2435x1369
  big star (41)      dagre 0           8104x 429 | radial 0  3576x2031

Neither wins outright and that is the finding. Ranks draw a chain better;
they draw a star as a ribbon a screen and a half wide and 250px tall, which
is the layout that got thrown out for being unreadable. A star has a good
drawing and it is the obvious one: hub in the middle, what it touches on a
ring around it, so the distance from the centre means hops from the subject
of the case. It only became drawable once the edges stopped leaving from a
fixed handle, because a ring needs its spokes to radiate.

Angles come from the breadth-first tree: each object owns a wedge, splits it
between its children in proportion to what hangs off each, and sits in the
middle of its own, so subtrees stay together. A crowded ring gives up on
wedges and shares itself out evenly rather than growing without bound. Then
siblings are re-sorted towards the mean direction of what they are joined to
elsewhere, which halved the crossings on Aviary, 8 down to 4.

Rings are squashed 1.7 to 1 rather than round. A card is four times wider
than it is tall, so the ring spacing is set by the widest case and a circle
pays that on the vertical too, on a screen that is wider than it is tall.
The tallest case went from 2639px to 2031 for the same width.

Two canvas bugs surfaced while testing this, both of which were making every
arrangement look worse than it was:

- the fit ran one frame too early and fitted to the PREVIOUS layout. The
  same arrangement clicked twice gave two different viewports, the second
  one right.
- passing minZoom to fitView never did anything. It changes the viewport
  being computed, and d3-zoom then clamps the transform to the canvas's own
  scale extent, which was the default 0.5. So the fit stopped at half size
  and every arrangement ran off the sides. The floor is set on the canvas
  now, where it takes effect.
… all round

Two things the floating anchors left behind.

A COLOUR PER FAMILY, not per verb. STIX has twenty-six verbs here, and six
more hues on top of the eighteen the objects already carry would say "these
differ" far louder than they differ - the argument stixMeta.ts makes about
the observables, and the same conclusion. What an analyst reads off a graph
is coarser than the verb anyway: who is behind this, what does it wield, who
does it hit, how would we see it, where does it live. Five questions, five
colours, and the verb stays written on the line for the detail. `related-to`
and anything a later spec adds read as unclassified rather than as a guess.

The colour travels as a custom property on the edge rather than as a stroke,
which is what lets the link focus paint over both the line and its label from
the stylesheet: an inline stroke would have won, and this file has no
`!important` in it. The label takes the same hue lifted towards the page's
foreground - at the stroke's own value it was legible for four families and
murky for the quietest.

GRIPS ON EVERY SIDE. There were two, a triangle pointing in at the top and
one pointing out at the bottom, and they were honest while every line arrived
at the top and left from the bottom. Now that a relationship meets the card
wherever the other object happens to be, a triangle at twelve o'clock claims
something untrue. So there is a grip per side, each a source and a target
laid over each other - the trick the annotation grip has always used - and
they are circles, because a triangle points and there is nothing left to
point at.

They are also hidden at rest, which is the half of the fix that matters: a
mark drawn there permanently was the thing reading as "the line arrives
here". The card offers them when you come near it, and while a link is being
dragged every card that could legally receive it shows its own, wherever the
pointer is. That last part is free: React Flow only marks a handle that can
END the connection in progress, and these targets refuse to start one, so
the mark means exactly "you may drop here".

The annotation grip moves to the bottom-right corner, out of the way of the
east one, and hides at rest by the same rules. A diamond for a note, which
has no direction, a circle for a relationship, which now leaves from
anywhere; they must not sit on top of each other while they say it.

Checked in the browser: dragging from a side grip to another card's side grip
opens the relationship dialog as before.
…head

The colour was computed and handed to the marker, and the edge's `data` was
never given it, so `FloatingEdge` set the custom property to `undefined` and
every line fell through to the default. Arrowheads were coloured, lines were
not, and I reported it working off screenshots rather than off the computed
style. Checked properly this time: uses reads 139,164,176, attributed-to
196,116,110, resolves-to 162,146,163.

And the annotation grip now says whether there is anything to read. A note
pinned beside the object is obvious; a note left in the inspector was
invisible until you clicked the object, which is the wrong way round - you
click BECAUSE you saw there was something there. So a card carrying one keeps
its diamond drawn at rest, in the hue the notes already use on the canvas,
while a card with nothing to say still only offers the grip on approach. An
opinion outranks a note and takes its own hue: it is the analyst's own
judgement, and the thing you least want to walk past.
The sidebar already names every STIX type in its own colour, so the cards
carry their own key. What had none was everything the canvas says ABOUT them:
the colour of a relationship, which groups five questions rather than naming
twenty-six verbs, and the mark on a card that carries a note.

On the canvas rather than in the help, because it is read WHILE looking at
the graph - glance at a line, glance at the key, glance back. A modal two
clicks away is where you learn something once, not where you check it.

Top right, under the toolbar. The bottom left belongs to the triage tray,
which is 300px wide and sits on top of whatever is there; the bottom right to
the minimap, which appears and disappears with the viewport width, so
anything stacked above it would have to move with it.

Open by default and shut for good once shut, remembered in localStorage.
Nobody learns a colour code from a panel they have to go and find, and nobody
wants it there a second week. Reading the setting is wrapped: a private
window and blocked site data both throw, and a legend is not worth taking the
canvas down for.

The swatches are strokes, not squares: a square of colour would read as an
object, and what is being named is a line. The two annotation marks are drawn
as the diamond they actually are.
Under the toolbar it read as a dropdown that had come loose from "My layout",
which is worse than the corner it was avoiding.

Beside the minimap it lands on the same baseline and at the same height, and
the bottom of the canvas reads as one strip of instruments rather than two
things that happen to be near each other. The map is 200 wide with a 15px
margin, so stepping 227px clear of the right edge leaves 12px between them.
Two columns to reach that height: six families in one list stand half again
as tall as the map.

The inline hints go with the change - at that width they were truncating
anyway, and the family names are the part that has to be read at a glance.
They stay on hover, where an explanation belongs.

The minimap comes and goes with the viewport width, so the legend takes the
corner itself below that breakpoint rather than leaving a hole where the map
would have been.
Seven arrangements go, five lenses arrive, and "By structure" is the only
thing left that touches a position.

The arrangements were built in a world where the graph could not be drawn.
Their stated virtue was that they never looked at the relationships, so
nothing about them could be misread - true and useful right up until the
radial could draw them. After that, ignoring them stopped being a precaution
and became the cost: the answer to "what has no indicator on it" is a SET of
objects, and piling that set into a block takes away the context that made
those objects mean anything. Which of them is wired to the malware is the
interesting half, and the pile threw it away. They had a second, dumber fault
on top: nothing named the blocks, so the analyst got six silent piles and had
to work out which was which. I had written that the shape of a group does the
naming. It does not.

A lens moves nothing. It lights what answers the question and steps the rest
back, on whatever layout is on screen, with the machinery the link focus
already uses. Relationships dim unless BOTH ends survived, or the answer sits
inside a web of bright lines going nowhere the question asked about, and the
annotations dim too. Esc puts everything back, and the menu marks the lens
that is on.

Only yes-or-no questions made the trip. "By type" was a partition, and it was
telling the analyst something every card already says in its own colour; "by
ATT&CK tactic" was a partition too, over the two techniques a typical case
holds. Both are gone rather than reworded.

layout.ts goes with them - clusters, gutters, seven groupers, its tests. What
radial.ts needed from it was three type declarations, which now live there.
The comparison harness keeps measuring dagre against the radial, and gained a
bound on each side of the argument: zero crossings on a star, which is the
shape the radial exists for, and no worse than eight on Aviary, which is the
shape it is weakest on.
Two things were putting them there.

They went to the RIGHT of their anchor. That was fine on a layered layout,
where right is nowhere in particular. On a radial one, right is straight at
the hub for everything sitting west of it, which is the busiest part of the
picture. They now go outward from the centre of mass of the drawing: the
quiet direction on any layout, and on the radial the empty one. Pushed per
axis rather than along a circle, so a note ends up a note's width to the side
or a note's height above, never a note's width above.

And the placer only knew about objects. A free cell is not a clear one: on a
radial layout the space around an object is crossed by its own spokes, so a
note could overlap nothing and still sit on three lines. It is now handed the
relationships as the segments they will be once everything has moved, and it
searches twice - once refusing to cross a line, once ignoring them - because
a note laid over a line still beats a note laid over an object.

Both paths get it, the radial and the Dagre one still in the command palette.
They earn their room on a small canvas and cost it on a big one. Three chips
is a third line of text on every object, and the labels an analyst tags with
are the same handful over and over, so on a crowded graph they say almost
nothing while taking almost as much ink as the names do.

A bare letter, like "/" for search and "l" for link focus: the canvas owns
the keyboard whenever no field does, and the same guard keeps it from firing
into a text field. Remembered in localStorage, because it is a way of looking
rather than a property of the case - it never travels in a bundle - and
because having to set it again every session is exactly the kind of thing
that makes a setting not worth having. Reading it is wrapped: a private
window and blocked site data both throw.

Hidden rather than shrunk. A smaller chip is still a chip.
… a hole

Two things, both from the same cause: a layout that only ever ran in one
context and was quietly wrong in the other.

The unsaved-changes marker already carried `margin-right: auto` so it would
pin itself left and leave the buttons where they were. The flex row it
needed for that was declared on `.modal .actions`, and the inspector is not
a modal - there the row was plain block flow, so the marker just took its
place in the line and shoved Save along it, once, the moment you typed your
first character. The row is now laid out wherever the form is shown.

And the cheat sheet was a two-column grid. A grid lines its rows up, so the
shorter group in a row is padded out to the height of the taller one beside
it: "Everywhere" ended up with a hand's width of nothing under it because
"Canvas" had grown by two entries. Columns flow instead, and `break-inside`
keeps a group whole.
The action row got its spacing from `.modal .actions`, so in the inspector it
sat straight against the field above it. It has its own now.

And the cheat sheet fills top to bottom rather than left to right. Filling
across put "Everywhere" beside "Canvas", and a grid lines its rows up, so the
short group was padded out to the height of the tall one and left a hand's
width of nothing in the middle. Letting the groups flow instead fixed that
and broke something else: the second heading in each column started at
whatever height the first one happened to end, and the columns stopped
reading as columns. Filling downwards gets both - "Everywhere" pairs with
"Triage tray", which are near enough the same size, and the one big group
pairs with the one small one, so the slack that is left sits in the bottom
corner where it reads as a margin.
The analyst's own vocabulary, listed most used first, with how many objects
carry each. Clicking one lights those objects and steps the rest back;
clicking it again, or Esc, puts the canvas back.

It shares the lens state rather than adding a second highlight, because it is
the same shape of question - which objects are these - and two answers would
otherwise fight over the same dimming. A label and one of the five questions
cannot both be on.

Its own rail panel and not a section of the objects one, which was the first
try. That panel is a creation palette: every chip in it makes something, and
a chip that filters instead would be a second verb wearing the same clothes.
Here the whole panel is about what is already on the canvas, so there is
nothing to mistake.

Labels are compared exactly and never case-folded. They are free text and
they drift, so `ransomware` and `Ransomware` are two labels and a bundle will
export them as two. Folding them here would hide the drift at exactly the
moment a list makes it visible, which is half of what the list is worth: it
is the only place in the app where you see your own vocabulary at once.

No colour dot on the chips, unlike the type chips above them - a vocabulary
the analyst coins has no palette, and inventing one would compete with the
codes the canvas already carries. The count carries the meaning instead: a
chip reading "ransomware 7" is plainly about what is there.
Two things a pass through the app as a user turned up, both of them in the
simplest case anyone meets.

The annotation grip was drawn at rest on every card, which is the one thing
hiding the grips at rest was meant to stop. The rule that shows a grip while
a link is being dragged named the whole pair, and a SOURCE handle carries
that mark at rest, because at rest it can start a connection. Only the target
half should answer a drag. Now a diamond means there is something to read,
and its absence means there is not: what carries the signal is the presence
of the mark, not its hue, which is a stronger channel than asking the eye to
compare colours across thirty corners.

And the ring gap was 40, which cleared the objects and left no room for what
is written between them. On two objects and one link - the first graph anyone
draws - "communicates-with" was clipped by both cards at once.

The new number is arithmetic rather than taste. The label is centred on the
middle of the path, so with a gap G it reaches G/2 plus half its width, and
the arrowhead sits between G-26 and G-14. The head clears the label when
G >= 2 * (half-label + 27), and the longest verb in the vocabulary is about
108px wide with its background, which puts the floor a shade over 160.

It costs eight per cent of width on a real investigation (Aviary 3485 to
3753) and nothing at all on a star, where the rings are pushed apart by the
objects standing side by side on them rather than by this. Crossings
unchanged at four.
Flex items shrink below their content, so "Import a bundle" and "Load the
example" wrapped onto two lines while "Create" did not. A row of buttons at
two different heights is the first thing anyone sees of the app. The labels
stay on one line and the field gives up the room instead.
Six, and the first one had teeth.

**The image export lost every relationship.** Moving the edge colour from an
inline style to a stylesheet variable broke the PNG/JPEG/PDF export: it
captures through html-to-image, which clones each edge's own <svg> without
inlining what its descendants compute, so the strokes came out absent and the
verbs black on the label background. This was known - a comment above `toEdge`
said exactly that, having been written the last time someone hit it - and the
comment survived while the code it guarded did not. Colours are set on the
element again, and the highlight with them, which as a side effect makes the
link focus reach the LINE for the first time: the inline stroke had always
been quietly winning against the rule meant to paint over it.

**A row without `properties` took the whole canvas down.** The lens index
dereferenced `properties.labels` and `.tlp` with no guard, unlike every other
reader of that field, one of which carries a comment about it being the single
failure with no way out from inside the app - the row is persisted, so every
reload crashes again. Guarded, `source` included, which `machineMade` calls
`startsWith` on.

**Escape cleared the lens on its way past.** Closing the search or dismissing
a canvas menu with Escape also put the lens away, because neither consumer
stopped the event and this listener is on the window. Both stop it now.

**Any lens faded the whole annotation layer.** A note or a capture matches no
lens - they are not entities - so every pinned note went dim even when the
object it hangs off was lit, which hid the analyst's reasoning about the very
objects the lens had picked out. They take the verdict of their anchor now,
links included.

**Two cards on the same spot lost their relationship entirely.** The anchor
pass skipped an edge whose ends share a centre, so nothing was drawn and, with
no path, nothing could be clicked either: the relationship could no longer be
inspected or deleted. It happens - the placer returns the requested point when
the neighbourhood is full, on the grounds that an overlap beats a node thrown
off the canvas. Any direction will do, and sideways is the one a card is
widest in.

**A bare `t` fired inside a focused `<select>`.** Typing a letter into an open
dropdown jumps to the option starting with it, and the shortcut was stealing
it. The guard now names `select` too, which fixes `l` and `/` with it.

Also from the review: relationship verbs are looked up in a Map rather than a
plain object, since a bundle may carry any verb it likes and `constructor`
came back as a function; and an edge rejected by both the link focus and a
lens no longer gets the same class twice.
The feature list had no line for what this branch is mostly about - the
graph being drawn, the colour code on the relationships, the lenses - and
the keyboard line stopped at four keys when there are seven.
The old one showed the layout that was thrown out, and none of what the
canvas has learned to say since: the graph drawn around what it is about,
the relationships coloured by what they say, the legend, the marks that tell
you an object carries a note.

The alt text followed: it announced the narrative panel, which is not what
is open on the right.
@pakdekro
pakdekro merged commit 722e459 into main Aug 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant