Skip to content

Commit 2def791

Browse files
refactor(library): one helper for the on-screen affordance scale
`arcScaleForZoom` and `getHandleScreenScale` were the same function written twice with different clamp spellings, and React Flow writes it a third time as its own `scaleSelector`. Keeps the descriptive name, in `geometry/scalar` alongside the other scalar helpers, so the arcs, the edge grips and the resize band all read the same definition. `ArcScalePublisher` also drops its hand-rolled store subscription. It now reduces to the scale inside a `useStore` selector — the idiom the file next door already uses, and the reason it re-renders only when the scale changes rather than on every frame — leaving a `useLayoutEffect` to write the custom property. The old `store.subscribe` had no selector, so it ran on every store mutation and needed its own dedup to avoid writing on node drags and selections; and as a plain effect it let the first frame after mount paint at the fallback scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuVfUjEN3BSUNFK5TyQ37k
1 parent 4a2bc5b commit 2def791

4 files changed

Lines changed: 66 additions & 67 deletions

File tree

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,23 @@
1-
import { useStore, useStoreApi } from "@xyflow/react"
2-
import { useEffect } from "react"
3-
import { CANVAS } from "@/constants"
4-
5-
/**
6-
* Connection indicators keep a usable minimum on-screen size when zoomed out and
7-
* grow with the node when zoomed in:
8-
* scale = 1 / min(zoom, 1) — constant on screen for zoom <= 1, natural above.
9-
*/
10-
export const arcScaleForZoom = (zoom: number): number =>
11-
1 /
12-
Math.min(
13-
Math.max(
14-
Number.isFinite(zoom) && zoom > 0 ? zoom : 1,
15-
CANVAS.MIN_SCALE_TO_ZOOM_OUT
16-
),
17-
1
18-
)
1+
import { useStore } from "@xyflow/react"
2+
import { useLayoutEffect } from "react"
3+
import { getHandleScreenScale } from "@/utils/geometry/scalar"
194

205
/**
216
* Publishes `--arc-scale` once for the whole canvas.
227
*
23-
* Every node's wrapper used to read `state.transform[2]` to write this custom
24-
* property onto each handle's inline style. That subscribes every node to the
25-
* zoom, so a single wheel gesture re-rendered every node on every frame — the
26-
* exact pattern React Flow's performance guide warns about, and the reason
27-
* zooming got heavier the more elements a diagram had.
28-
*
29-
* Custom properties inherit, so the value only has to exist on an ancestor. This
30-
* writes it straight to the DOM from a store subscription instead of rendering:
31-
* no node re-renders, and no React render at all per frame — just one style
32-
* property set, and only when the value actually changes.
8+
* Custom properties inherit, so the value only has to exist on an ancestor.
9+
* Publishing it here rather than from each node's style keeps zoom out of every
10+
* node's subscription list, and reducing to the scale inside the selector means a
11+
* re-render only when the scale itself changes, not on every frame of a zoom.
3312
*/
3413
export const ArcScalePublisher = () => {
3514
const domNode = useStore((state) => state.domNode)
36-
const store = useStoreApi()
37-
38-
useEffect(() => {
39-
if (!domNode) return
40-
41-
let published: number | undefined
42-
const write = (zoom: number) => {
43-
const scale = arcScaleForZoom(zoom)
44-
// Zoom changes every frame of a gesture; the scale it maps to does not once
45-
// zoomed past 1, and the DOM write is skipped whenever it has not moved.
46-
if (scale === published) return
47-
published = scale
48-
domNode.style.setProperty("--arc-scale", String(scale))
49-
}
15+
const scale = useStore((state) => getHandleScreenScale(state.transform[2]))
5016

51-
write(store.getState().transform[2])
52-
return store.subscribe((state) => write(state.transform[2]))
53-
}, [domNode, store])
17+
// Layout effect: this is a style the same frame paints with.
18+
useLayoutEffect(() => {
19+
domNode?.style.setProperty("--arc-scale", String(scale))
20+
}, [domNode, scale])
5421

5522
return null
5623
}

library/lib/edges/GenericEdge.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,10 @@ import { Assessment } from "@/typings"
2323
import type { BendHandle } from "@/utils/geometry/bendHandles"
2424
import { getSegmentGhostHandles } from "@/utils/geometry/freeWaypoints"
2525
import { isFreeformEdgeAnchor } from "@/utils/edgeUtils"
26-
import { CANVAS, EDGES } from "@/constants"
26+
import { EDGES } from "@/constants"
2727
import { useLabels } from "@/i18n/useLabels"
28+
import { getHandleScreenScale } from "@/utils/geometry/scalar"
2829

29-
// Edge handles live inside the zoomed React Flow viewport. We want them to
30-
// keep a usable MINIMUM on-screen size when zoomed out (so they never shrink to
31-
// a few px), but to GROW with the edge when zoomed in (so they stay in
32-
// proportion to the thick edge instead of looking like a tiny dot on it).
33-
//
34-
// scale = 1 / min(zoom, 1) (zoom floored to the canvas minimum)
35-
// zoom <= 1 → 1/zoom → constant on-screen size (counter-scaled)
36-
// zoom > 1 → 1 → natural flow size → grows on-screen with zoom
37-
export const getHandleScreenScale = (zoom: number): number => {
38-
const safeZoom = Math.max(
39-
Number.isFinite(zoom) && zoom > 0 ? zoom : 1,
40-
CANVAS.MIN_SCALE_TO_ZOOM_OUT
41-
)
42-
return 1 / Math.min(safeZoom, 1)
43-
}
44-
45-
// The reduction runs INSIDE the selector so Zustand compares the scale rather than
46-
// the raw zoom. The scale is exactly 1 for every zoom >= 1, so zooming in stops
47-
// re-rendering these handles entirely; selecting `transform[2]` and reducing
48-
// outside re-rendered every edge's handles on every frame of every gesture, which
49-
// is the bulk of what made zooming heavy on a diagram with edges.
5030
const useHandleScreenScale = (): number =>
5131
useStore((state) => getHandleScreenScale(state.transform[2]))
5232

library/lib/utils/geometry/scalar.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { CANVAS } from "./routingConstants"
2+
13
/** Constrain `v` to `[lo, hi]`. */
24
export const clamp = (v: number, lo: number, hi: number): number =>
35
Math.max(lo, Math.min(hi, v))
@@ -19,3 +21,22 @@ export const lexLess = (
1921
}
2022
return false
2123
}
24+
25+
/**
26+
* On-screen scale for canvas affordances that must stay grabbable when zoomed out
27+
* without looking undersized when zoomed in:
28+
*
29+
* zoom <= 1 → 1/zoom → counter-scales, so the affordance holds a constant
30+
* on-screen size (zoom floored to the canvas minimum)
31+
* zoom > 1 → 1 → natural flow size, so it grows with the node it sits on
32+
*
33+
* Reduce with this inside a Zustand selector rather than selecting the raw zoom:
34+
* the result is exactly 1 for every zoom >= 1, so zooming in re-renders nothing.
35+
*/
36+
export const getHandleScreenScale = (zoom: number): number => {
37+
const safeZoom = Math.max(
38+
Number.isFinite(zoom) && zoom > 0 ? zoom : 1,
39+
CANVAS.MIN_SCALE_TO_ZOOM_OUT
40+
)
41+
return 1 / Math.min(safeZoom, 1)
42+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest"
2+
import { getHandleScreenScale } from "@/utils/geometry/scalar"
3+
import { CANVAS } from "@/constants"
4+
5+
/**
6+
* Connection indicators hold a usable on-screen size when zoomed out and grow with
7+
* the node when zoomed in. The identity above 1 is what lets the publisher skip
8+
* writes for most of a zoom-in gesture.
9+
*/
10+
describe("getHandleScreenScale", () => {
11+
it("leaves the arc at its natural size once zoomed in", () => {
12+
expect(getHandleScreenScale(1)).toBe(1)
13+
expect(getHandleScreenScale(2)).toBe(1)
14+
})
15+
16+
it("counter-scales below 1 so the arc keeps its on-screen size", () => {
17+
expect(getHandleScreenScale(0.5)).toBe(2)
18+
})
19+
20+
it("clamps rather than diverging at a very small zoom", () => {
21+
expect(getHandleScreenScale(CANVAS.MIN_SCALE_TO_ZOOM_OUT / 10)).toBe(
22+
1 / CANVAS.MIN_SCALE_TO_ZOOM_OUT
23+
)
24+
})
25+
26+
it("falls back to natural size for a zoom that is not a positive number", () => {
27+
expect(getHandleScreenScale(0)).toBe(1)
28+
expect(getHandleScreenScale(-1)).toBe(1)
29+
expect(getHandleScreenScale(Number.NaN)).toBe(1)
30+
})
31+
})

0 commit comments

Comments
 (0)