Skip to content

Animate firings only where they can be seen, hold the off-screen timeline, and pin the place visualizer - #9601

Draft
kube wants to merge 15 commits into
claude/canvas-frame-and-adjacencyfrom
claude/canvas-firing-visibility-and-pin
Draft

Animate firings only where they can be seen, hold the off-screen timeline, and pin the place visualizer#9601
kube wants to merge 15 commits into
claude/canvas-frame-and-adjacencyfrom
claude/canvas-firing-visibility-and-pin

Conversation

@kube

@kube kube commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Before this PR, playing or scrubbing a large net cost far more than the canvas draws. Every transition that fires animates its box, its bolt and each of its arcs, and the style engine resolves and paints each of those for as long as it runs. On a thousand-node net that is hundreds of animations in flight per frame, on nodes a few pixels across that nobody is looking at. The simulation timeline added its own share: a closed bottom panel is moved off the viewport rather than unmounted, so the chart went on reading every frame and repainting an invisible canvas.

A firing now animates only where it can be seen: nodes and arcs off the side of the pane, and any net drawn small enough that a node covers a few pixels, skip the flash. A timeline that is off screen stops reading frames and stops drawing until it comes back, and the chart's columns are extended as frames arrive rather than re-summed from the start of the run. A place's state visualizer is reachable from the canvas in two steps, a button on hover and the panel on a click, and gains a pin so it can be watched while the timeline is scrubbed. It opens whether or not anything has run.

Scenario Before After
Scrub, 1000 nodes 13 fps 44 fps
Playback, 1000 nodes 16 fps 39 fps
Playback, 200 nodes, panel closed 33 fps 46 fps
Chart repaints, 4 s, panel closed 172 0

Measured against the built website in headless Chromium, driving a ring net of the stated size through a run, with frame intervals sampled from requestAnimationFrame and repaints counted on the chart canvas.

9601-two-step.mp4

Links

Changes

Canvas

  • Firing animations run only on what is on screen

    A node's position and an arc's endpoints are projected through React Flow's transform in the effect that starts the animation.
    Reading the transform there rather than subscribing to it keeps a pan or a zoom from re-rendering every node.

  • Firing animations stop below a zoom of 0.25

    A transition's box is around 40px wide, so this is the point where its flash covers ten pixels.

  • Hovering is carried on the scene in its own right, not read off the focus

    Highlight on hover switches the focus off, which took the state visualizer with it.
    CanvasFocus loses its hoveredId, which nothing outside the focus model read.

Simulation timeline

  • Off-screen timeline stops reading frames and stops drawing

    An IntersectionObserver on the chart's container drives it, so a panel slid away, a scrolled page and a clipped container are all covered.
    The frame cursor is kept, so coming back appends everything that arrived meanwhile in one batch.

  • Chart columns are extended as rows arrive

    A stacked chart re-summed the whole run per frame: 500 series a thousand frames in is half a million additions to move the chart on by one column.
    A run chart allocated a fresh array of gaps per hidden series per frame; every hidden series now shares one that only grows.

  • useElementOnScreen reports whether an element overlaps the viewport

    Starts true and stays true where IntersectionObserver is unavailable, so a caller that gates work on it does that work.

Place state visualizer

  • Pointing at a place offers a button, and clicking it opens the visualizer

    A panel that opens on the hover alone covers the net while somebody is only passing over it. The button takes the slot the panel will occupy, so the panel is what grows out of it.
    Opening belongs to that hover: one place id, dropped as soon as the pointer moves to another place or off the canvas, so coming back offers the button again. Clicking it does not pin.

  • Pin holds the visualizer open

    Pinned places live on the editor state and reach the node through the scene, so a pin re-renders one node.
    Several places can be pinned at once; pins last until the page is reloaded.

  • Panel opens with no run, drawing the initial marking

    A place could not be pinned while the net was still being built, which is when watching one place's state against its initial marking is most useful.
    The properties panel already previewed that state, so the two now agree. Frames availability leaves the canvas frame store with the gate that read it.

  • Pin is a disc of frosted glass over the visualizer's top-right corner

    It blurs and tints what the visualizer draws behind it, so it takes the artwork's own colour and still lifts it enough for the glyph to read over a pale picture and a black one alike.
    Held back at a little under three quarters until the pointer or the keyboard reaches the box, full strength while pinned. Verified legible over the satellites example's black sky and the disruption example's pale blue.
    Its focus ring belongs to the disc and is drawn in two tones, a light ring inside a dark halo, because it floats over artwork of any colour.

  • Box grows into place rather than appearing

    120ms of opacity and 160ms of scale from 0.96, out of the edge facing its node, on the measurement the box already waits for.
    Held still where the Animations setting is off or the reader prefers reduced motion.

  • Box does not exist until it has artwork to draw

    The module that draws a visualizer is held in state rather than behind a boundary inside the box. A boundary there let the box mount, measure itself at its floor, and spend its opening on an empty 38x34 rectangle; hoisting the boundary instead left the box invisible, because the retry that brings the artwork in re-renders only the suspended subtree and never the measurement.

  • Box keeps the hover while the pointer is on it

    Reaching for the pin used to end the hover and take the box with it. The gap between node and box is the wrapper's own padding, so the pointer crosses it without touching the canvas.
    The box has always been scrollable and could never be scrolled.

  • Dragging a place leaves its box behind

    Otherwise the box rides over the canvas the node is being dropped on, redrawing the visualizer every frame of the drag.

  • Visualizer compiles once for its code

    Compiling happens wherever a visualizer is shown, and the box now shows it on every hover, so the same code went through Babel again and again. Results and failures are kept by source, bounded and oldest-first.

  • Visualizer is redrawn only when what it draws moves

    Rendering one runs user code, and its tokens were derived fresh every render, so a hover settling, a pin, or a box being measured redrew the picture. It is now held against the frame, the marking, the parameters and its own code.
    On a scrub with a 100ms picture pinned: 45 to 63 frames per second, worst frame 92ms to 51ms.
    useDeferredValue on the frame does not belong on top of that, and the comment says so: it measured 63 down to 54-59 with the picture held, and 45 down to 35 without it, because the update that moves the frame is the one that has to redraw.

Test coverage

  • firing-animation-visibility.test.ts:

    Projection through pan and zoom, the zoom floor, and arcs that run in either direction.

  • chart-data.test.ts:

    Stacking order, hidden series, extension matching a rebuild, and starting over when a run resets.

  • canvas-scene.test.ts:

    A hovered node with no focus, and a pinned place.

  • compile-visualizer.test.ts:

    One component per source, a fresh compile for code that differs, and a kept failure.

  • Playwright, against the built website:

    Pin reachable from a hover, holding through a scrub, and releasing on a second click.

  • Playwright, the two steps:

    Button on hover and no panel, the button surviving being reached across the gap, the panel on the click and not pinned by it, closing when the hover leaves, the button offered again on return, and a second place offering its own button while one panel is pinned.

  • Playwright, with nothing run:

    Panel open after the click, pin held back on the node and full on the panel or under keyboard focus, pin inside the panel's corner, pinning without selecting the place, and the panel gone mid-drag and back after the drop.

  • Playwright, the opening:

    Opacity and transform sampled from the hover: 0 and scale 0.96 to 1 and scale 1 over about 120ms, origin settling on the edge facing the node, and no transition at all with the Animations setting off or under reduced motion.

  • Playwright, the scrub A/B:

    Frame intervals over a scrub with an expensive visualizer pinned, run against builds with and without the held picture and with and without a deferred frame.

  • Existing @hashintel/petrinaut unit suite.

How to test

Firing animations

  • Open Petrinaut preview on Vercel
  • Menu > Load example > Supply Chain with Disruption
  • Press Play

    Expect transitions to flash and arcs to thicken as they fire

  • Zoom out until nodes are a few pixels across

    Expect no flashes, token counts and the timeline still moving

  • Zoom back in

    Expect flashes on the transitions in view

Timeline off screen

  • Press Play
  • Close the bottom panel

    Expect playback to run visibly smoother

  • Reopen the timeline

    Expect the chart to show the whole run, including the frames computed while it was shut

  • Timeline header > Stacked

    Expect stacked bands to match the run chart's totals

Two steps, and the pin

  • Point at InboundShipments, before running anything

    Expect a small round button above the place, and no panel

  • Click it

    Expect the visualizer to open where the button was, drawing the initial marking, with a faint pin over its top-right corner

  • Move the pointer away, then back onto the place

    Expect the panel to close, and the button to be offered again rather than the panel

  • Click the button, then move the pointer onto the panel

    Expect the pin to come up to full strength

  • Click the pin, then drag the place somewhere else

    Expect the panel to go while the place moves, and to come back where it lands

  • Press Play, then Pause
  • Drag along the timeline chart

    Expect the pinned panel to follow the scrubbed frame

  • Click the pin again, then move the pointer away

    Expect the panel to close

kube added 3 commits September 9, 2026 05:08
Every transition that fires animates its box, its bolt and each of its
arcs, and the style engine resolves and paints each of those animations
for as long as it runs. On a large net that is hundreds in flight at
once, costing more per frame than everything else the canvas does.

Most of that work lands where nobody is looking: on nodes off the side
of the pane, or on a net drawn small enough that the flash covers a few
pixels. Both are now skipped. On a thousand-node net a scrub goes from
13 to 44 frames per second, and playback from 16 to 39.
A closed bottom panel is moved off the viewport rather than unmounted,
so the timeline went on reading every frame of a run and repainting an
invisible canvas: 172 repaints over four seconds of playback with the
panel shut.

An IntersectionObserver on the chart's container holds both the reads
and the drawing while it is off screen; opening the panel again catches
up in one batch. Closing the panel during a 200-node run now takes
playback from 33 to 46 frames per second.

The chart's columns are also extended rather than rebuilt: a stacked
chart re-summed the whole run on every frame, which on 500 series a
thousand frames in was half a million additions to move the chart on by
one column.
The visualizer follows the pointer, which is exactly the wrong
behaviour for watching a place while scrubbing the timeline or editing
the initial state: the box goes as soon as you reach for either. A pin
in its corner holds it open, and the box now keeps the hover while the
pointer is on it, so the pin can be reached at all — and the box, which
has always been scrollable, can finally be scrolled.

Hovering also stops riding on the focus highlight, which the hover
highlight setting can switch off: with the setting off the visualizer
never appeared.
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hash Building Building Preview Sep 9, 2026 2:30pm UTC
petrinaut Building Building Preview Sep 9, 2026 2:30pm UTC
petrinaut-docs Building Building Preview Sep 9, 2026 2:30pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
hashdotdesign-tokens Ignored Ignored Preview Sep 9, 2026 2:30pm UTC

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team labels Sep 9, 2026
The box is a portal but still a child of the node in the React tree, so
a click on the pin bubbled to the node and selected the place, opening
its properties panel.
kube added 2 commits September 9, 2026 13:37
The box only opened once a run had frames, so a place could not be
pinned while the net was still being built -- which is when watching one
place's state while editing its initial marking is most useful. It now
opens on hover whatever the state of the run, drawing the initial
marking before one exists, as the properties panel already did.

The pin moved from beside the visualizer to over its top-right corner,
so the box is the size of the artwork. It sits at a third of its
strength until the pointer or the keyboard reaches the box, and holds
full strength while pinned. The chip around it is opaque and bordered:
every button variant in the system paints in translucent ink meant for
the app's own background, and the satellites example draws a black sky.

Dragging a place no longer carries its box along, the box keeps the
visualizer's own height, and it holds a floor big enough for the pin.

frames availability is gone from the canvas frame store with the gate
that read it.
A visualizer is compiled wherever it is shown, and the canvas box now
shows it on every hover of its place, so the same few hundred lines went
through Babel again and again. The result is kept by its source, the
failure too, since a visualizer that does not compile is asked for just
as often as one that does. The cache is bounded and oldest-first:
editing the code makes an entry per keystroke and only the latest is
worth holding.
kube added 2 commits September 9, 2026 14:17
The box appeared at full size the instant it was measured. It now grows
the last few per cent into place from the edge facing its node, over
120ms of opacity and 160ms of scale, and holds still where the
Animations setting is off or the reader prefers reduced motion.

The pin is a disc of frosted glass rather than a filled chip: it blurs
and tints what the visualizer draws behind it, so it takes the artwork's
own colour while lifting it enough for the glyph to read over a pale
picture and a black one alike, and holds back at less than three
quarters until the box is pointed at. The thumbtack is redrawn rounder
and thinner to match.
Rendering a visualizer runs user code, and the tokens it takes were
derived fresh on every render, so every re-render of the surface it sits
on redrew the picture: a hover settling, a pin, a box being measured. It
is now held against the frame, the marking, the parameters and its own
code. Measured on a scrub with a 100ms picture pinned to the canvas: 45
to 63 frames per second, worst frame 92ms to 51ms.

Concurrent rendering was tried here and does not pay. Deferring the
frame with useDeferredValue measured 63 down to 54-59 with the picture
held, and 45 down to 35 without it: the update that moves the frame is
the one that has to redraw, so nothing is left to defer that holding the
picture has not already skipped, and the second priority pass costs a
whole extra draw. The finding is recorded where the memo is.
The opening was spent on nothing. The visualizer arrives with the chunk
that draws it, and the boundary waiting for that sat inside the box, so
the box mounted, measured itself at its minimum, played its 120ms on a
38x34 rectangle, and the artwork appeared afterwards at full size.
Hoisting the boundary above the box does not fix it either: the retry
that brings the artwork in re-renders only the suspended subtree, so the
measurement that reveals the box never runs and the box stays
invisible. Sampled from the hover, the box now appears at 298x104 and
scales to 310x108 as the opacity crosses 0.22, 0.59, 0.91.

So the module is held in state instead, and the box does not exist
until there is something to draw in it. Both the initial value and the
setter wrap it: a component is a function, and React took the bare one
for a lazy initialiser and called it with no props, which crashed the
editor on the second open of any box -- after a drag, say.

Also from the review: the box is measured border-box, so the decision
to flip above or below is no longer 10px out, and the compile cache
evicts least-recently-used rather than oldest-first, so a burst of
keystrokes stops flushing the visualizers being looked at.
Clipping the disc to keep the button's ink round erased the button's
own focus ring with it: tabbing to the pin lit nothing at all. The
button is round in its own right now, so nothing needs clipping, and
the ring belongs to the disc, drawn outside it in two tones because it
floats over whatever the visualizer draws -- a light ring carries a
dark picture, a dark halo beyond it carries a pale one.

Also drops a dead declaration: the disc set a colour for the glyph, and
the button inside it always sets its own.
A rounded square rather than a circle, on the radius the button inside
it already uses so the two share a corner, and 8px clear of the box's
top and right edges rather than 5px. Nothing else about the glass
changes.
The box curves at 14px now rather than 6px, and every corner inside it
is measured from that one number in CSS: a shape inset by n from the box
curves at 14px minus n, so the curves stay parallel instead of the
tighter ones crowding inside the wider ones. The pin's glass sits 8px in
and curves at 6px, and the button inside the glass a further 1px in at
5px.

The artwork reaches the border rather than sitting in a frame of
padding, which is what the corners were fighting: a visualizer draws its
own background square to its viewBox, and inside a padded box that
square corner sat plainly within the box's rounded one. Scrolling clips
to the padding box, which follows the radius, so with the padding gone
the artwork's corners simply are the box's.
A place's picture is worth a panel, and a panel that opens on the hover
alone covers the net while somebody is only passing over it. Pointing at
a place now offers one small round button in the slot the panel will
occupy, and the panel is what clicking it produces.

Opening belongs to the hover that did it: the state is one place id,
dropped as soon as the pointer moves to another place or off the canvas,
so coming back offers the button again rather than the panel. Pinning is
still the only thing that outlasts a hover, and clicking the button does
not pin.

The module that draws a visualizer is only needed for the panel, so the
button is offered while it is still loading.
…the hover

The button floated clear of the node, with the panel's own gap beneath
it. Its centre is now on the node's edge, half over it: the toolbar
offset is half the button's own size, so the two are stated together. It
needs none of the panel's padding either, because the pointer reaches it
off the node without crossing the canvas in between.

Coming back from the button or the panel to the node dropped the node's
highlight and closed the button with it, and nothing brought either
back. React routes enter and leave through its own tree, and the toolbar
is a portal inside the node's subtree there, so the return fires the
surface's leave and no enter on the node at all. Leaving onto a node is
not leaving: landing on this one means the pointer never really left,
and landing on another is that node's business, whose own enter
arrives.
Its white came from the button itself, and every button variant in the
system paints its hover in translucent ink meant for the app's own
background -- that ink replaced the white, so pointing at the button
turned it see-through and the node showed through the glyph. The
surface moved out to the wrapper, where the ink lands on top of it as
designed, exactly as the pin's glass already did.

The response to being pointed at is now the surface warming to s30 and
lifting its shadow, because the button's own ink is a fortieth of black
and over white that is no response at all. Its focus ring moved out with
the surface, where nothing clips it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

1 participant