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
Draft
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
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.
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.
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.
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.
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.
Measured against the built website in headless Chromium, driving a ring net of the stated size through a run, with frame intervals sampled from
requestAnimationFrameand repaints counted on the chart canvas.9601-two-step.mp4
Links
Changes
Canvas
Simulation timeline
useElementOnScreenreports whether an element overlaps the viewportPlace state visualizer
Test coverage
firing-animation-visibility.test.ts:chart-data.test.ts:canvas-scene.test.ts:compile-visualizer.test.ts:@hashintel/petrinautunit suite.How to test
Firing animations
Timeline off screen
Two steps, and the pin