Skip to content

FE-1510: Render the Petrinaut canvas centered in a single initial render - #9352

Merged
kube merged 3 commits into
mainfrom
claude/fe-1510-canvas-single-initial-render
Aug 27, 2026
Merged

kube merged 3 commits into
mainfrom
claude/fe-1510-canvas-single-initial-render

Conversation

@kube

@kube kube commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🌟 What is the purpose of this PR?

Opening a net painted the canvas twice: once at the origin at zoom 1, then again once a fitView effect centered it. The canvas now renders centered on the net from its very first frame. petrinaut-core owns node dimensions, net bounds and zoom limits, and the canvas derives its initial viewport and zoom floor from them during render — no fit effects, no manual memoization, React Compiler throughout.

Kapture.2026-08-27.at.01.59.53.mp4

🔗 Related links

🔍 What does this change?

  • petrinaut-core layout/ gains the render dimension constants (moved from the UI), getComponentInstanceHeight (port-based height), and a new geometry.ts with getBoundsOfCenteredBoxes, getMinZoomForBounds and ZOOM_PADDING. layoutNodeDimensions is now derived (per-axis max) instead of hand-copied.
  • SDCPNView measures its container (useContainerSize: layout effect + ResizeObserver, null until the element has area; the first real size lands pre-paint, later sizes only once they have settled for 100ms, so resizes and panel animations re-render once instead of per frame) and only then mounts <ReactFlowProvider key={petriNetId}> with the new SDCPNCanvas. The initial viewport is computed in a lazy useState from core bounds and React Flow's pure getViewportForBounds, and passed as defaultViewport — the first render is the final one. Switching nets re-centers via the keyed remount, replacing the fitView-on-id-change effect.
  • minZoom is derived during render: the core candidate, clamped by a useStore selector that yields the current zoom only while it is below the floor, then debounced with a new useDebouncedValue. The lodash useDebounceCallback and useResizeObserver utilities are deleted; the onInit/instance-state/onViewportChange plumbing is gone.
  • Node CSS no longer duplicates sizes: React Flow sizes the node wrapper from the declared width/height and the cards fill it. This corrects two silent drifts (compact cards declared 48px but rendered 50px; classic component instances declared 120px but rendered 96px) and makes component instances actually grow with their port count.
  • ui/lib/viewport.ts is now pure: bounds come from core instead of instance.getNodesBounds, and getViewportRect takes a size instead of a DOM element. The recenter-on-panel-open hook uses useReactFlow and the measured container size.
  • Auto-layout on import uses layoutNodeDimensions, so its output no longer depends on the compact/classic setting (the drift the core docs warned about).

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

No user-guide page describes initial centering or the zoom floor; behaviour is the same, minus the initial jump.

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

Intentional behaviour deltas, all making declared values match what was actually rendered:

  • Compact node dimension constant is 50px (was 48; the card always rendered ~50).
  • Classic component-instance dimension is 96px (was 120; the CSS always rendered 96), so derived layout dimensions for instances are 96 — auto-layout spacing around instances tightens slightly.
  • Component instances with more than 2 ports now actually grow, spreading their port handles.

🐾 Next steps

🛡 What tests cover this?

  • New: layout/geometry.test.ts and layout/dimensions.test.ts in petrinaut-core (bounds, min-zoom formula, port heights, derived layout dims).
  • Extended: ui/lib/viewport.test.ts covers getInitialViewport (centering, zoom cap, large-net fit) and its fixtures moved to the center-origin convention the runtime actually uses.

❓ How to test this?

  1. yarn workspace @hashintel/petrinaut dev, open the Petrinaut → Handle Spike With Sir story.
  2. The net appears centered immediately — no jump after load, no React Flow container warnings in the console.
  3. Zoom out: the floor stops the net at a readable fraction of the viewport. Toggle compact/classic in the viewport settings: positions hold and node DOM sizes match the declared dimensions exactly.

📹 Demo

Screenshots pending.

🤖 Generated with Claude Code

The canvas previously painted once at the origin, then a useEffect asked
React Flow to measure and fitView, causing a visible jump. Node dimension
constants, net bounds and min-zoom math now live in petrinaut-core; the
view measures its container before mounting React Flow and derives the
initial viewport and zoom floor during render, so the first frame is the
final one. Net switches re-center via a keyed remount instead of an
effect, and the lodash debounce utilities are replaced by a debounced
value hook. Node CSS fills the wrapper React Flow sizes from the declared
dimensions, removing the duplicated (and drifted) size constants, and
auto-layout on import uses the layout-stable dimensions.
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview Aug 27, 2026 12:03pm
petrinaut Ready Ready Preview Aug 27, 2026 12:03pm
petrinaut-docs Ready Ready Preview Aug 27, 2026 12:03pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Aug 27, 2026 12:03pm

Request Review

The container size state updated on every ResizeObserver tick, so a
window resize or panel animation re-rendered the canvas once per frame.
Nothing consumes per-frame sizes: the initial viewport reads the size at
mount, the zoom floor wants settled values, and the recenter hook reads
it at panel-open instants. useContainerSize now reports the first real
size immediately (the pre-paint measurement that mounts the canvas) and
later sizes only once they have held still for 100ms.
@kube
kube marked this pull request as ready for review August 26, 2026 23:51
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large refactor of canvas mount timing, viewport/zoom behavior, and layout dimension sources; intentional spacing changes for classic component instances and multi-port instances.

Overview
petrinaut-core now owns canvas geometry: compact/classic render dimensions, port-based getComponentInstanceHeight, centered-box bounds, and min-zoom math (ZOOM_PADDING). layoutNodeDimensions is derived as the per-axis max of those modes instead of hand-maintained constants.

The SDCPN canvas no longer paints at the origin then fitView. SDCPNView waits for a measured container (useContainerSize), then mounts SDCPNCanvas inside a ReactFlowProvider keyed by net id with defaultViewport from getInitialViewport (core bounds + React Flow’s getViewportForBounds). minZoom is computed on render from core, with a debounced floor that respects zoom already below the limit. Viewport helpers no longer call getNodesBounds on a React Flow instance.

React Flow nodes get explicit width/height from core; node cards fill the wrapper so sizes match declarations. Import auto-layout always passes layoutNodeDimensions, not compact/classic. Lodash debounce/resize utilities and UI node-dimensions.ts are removed in favor of useDebouncedValue and core exports.

Reviewed by Cursor Bugbot for commit b2065e5. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread .changeset/canvas-single-render.md Outdated
YannisZa
YannisZa previously approved these changes Aug 27, 2026
@kube
kube added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit 8fac390 Aug 27, 2026
72 checks passed
@kube
kube deleted the claude/fe-1510-canvas-single-initial-render branch August 27, 2026 12:50
@hash-release hash-release Bot mentioned this pull request Aug 27, 2026
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.

3 participants