UI/node shape - #10
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
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
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 .