@graview/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +96 -0
  2. package/README.md +20 -0
  3. package/dist/activity.d.ts +113 -0
  4. package/dist/activity.d.ts.map +1 -0
  5. package/dist/activity.js +207 -0
  6. package/dist/activity.js.map +1 -0
  7. package/dist/animation.d.ts +64 -0
  8. package/dist/animation.d.ts.map +1 -0
  9. package/dist/animation.js +245 -0
  10. package/dist/animation.js.map +1 -0
  11. package/dist/captions.d.ts +43 -0
  12. package/dist/captions.d.ts.map +1 -0
  13. package/dist/captions.js +61 -0
  14. package/dist/captions.js.map +1 -0
  15. package/dist/channels.d.ts +15 -0
  16. package/dist/channels.d.ts.map +1 -0
  17. package/dist/channels.js +114 -0
  18. package/dist/channels.js.map +1 -0
  19. package/dist/connectors.d.ts +176 -0
  20. package/dist/connectors.d.ts.map +1 -0
  21. package/dist/connectors.js +837 -0
  22. package/dist/connectors.js.map +1 -0
  23. package/dist/context.d.ts +348 -0
  24. package/dist/context.d.ts.map +1 -0
  25. package/dist/context.js +568 -0
  26. package/dist/context.js.map +1 -0
  27. package/dist/drawn.d.ts +60 -0
  28. package/dist/drawn.d.ts.map +1 -0
  29. package/dist/drawn.js +58 -0
  30. package/dist/drawn.js.map +1 -0
  31. package/dist/edge-signs.d.ts +13 -0
  32. package/dist/edge-signs.d.ts.map +1 -0
  33. package/dist/edge-signs.js +76 -0
  34. package/dist/edge-signs.js.map +1 -0
  35. package/dist/hooks.d.ts +160 -0
  36. package/dist/hooks.d.ts.map +1 -0
  37. package/dist/hooks.js +457 -0
  38. package/dist/hooks.js.map +1 -0
  39. package/dist/index.d.ts +37 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +22 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/keyboard.d.ts +5 -0
  44. package/dist/keyboard.d.ts.map +1 -0
  45. package/dist/keyboard.js +178 -0
  46. package/dist/keyboard.js.map +1 -0
  47. package/dist/kit.d.ts +10 -0
  48. package/dist/kit.d.ts.map +1 -0
  49. package/dist/kit.js +32 -0
  50. package/dist/kit.js.map +1 -0
  51. package/dist/local-intelligence.d.ts +31 -0
  52. package/dist/local-intelligence.d.ts.map +1 -0
  53. package/dist/local-intelligence.js +66 -0
  54. package/dist/local-intelligence.js.map +1 -0
  55. package/dist/motion.d.ts +17 -0
  56. package/dist/motion.d.ts.map +1 -0
  57. package/dist/motion.js +26 -0
  58. package/dist/motion.js.map +1 -0
  59. package/dist/occupants.d.ts +45 -0
  60. package/dist/occupants.d.ts.map +1 -0
  61. package/dist/occupants.js +57 -0
  62. package/dist/occupants.js.map +1 -0
  63. package/dist/parallel.d.ts +19 -0
  64. package/dist/parallel.d.ts.map +1 -0
  65. package/dist/parallel.js +30 -0
  66. package/dist/parallel.js.map +1 -0
  67. package/dist/picking.d.ts +30 -0
  68. package/dist/picking.d.ts.map +1 -0
  69. package/dist/picking.js +140 -0
  70. package/dist/picking.js.map +1 -0
  71. package/dist/plots.d.ts +106 -0
  72. package/dist/plots.d.ts.map +1 -0
  73. package/dist/plots.js +306 -0
  74. package/dist/plots.js.map +1 -0
  75. package/dist/pointer.d.ts +27 -0
  76. package/dist/pointer.d.ts.map +1 -0
  77. package/dist/pointer.js +39 -0
  78. package/dist/pointer.js.map +1 -0
  79. package/dist/presence.d.ts +110 -0
  80. package/dist/presence.d.ts.map +1 -0
  81. package/dist/presence.js +280 -0
  82. package/dist/presence.js.map +1 -0
  83. package/dist/rails.d.ts +11 -0
  84. package/dist/rails.d.ts.map +1 -0
  85. package/dist/rails.js +15 -0
  86. package/dist/rails.js.map +1 -0
  87. package/dist/resolved-view.d.ts +78 -0
  88. package/dist/resolved-view.d.ts.map +1 -0
  89. package/dist/resolved-view.js +365 -0
  90. package/dist/resolved-view.js.map +1 -0
  91. package/dist/robot.d.ts +114 -0
  92. package/dist/robot.d.ts.map +1 -0
  93. package/dist/robot.js +93 -0
  94. package/dist/robot.js.map +1 -0
  95. package/dist/routes.d.ts +50 -0
  96. package/dist/routes.d.ts.map +1 -0
  97. package/dist/routes.js +150 -0
  98. package/dist/routes.js.map +1 -0
  99. package/dist/scene-camera.d.ts +25 -0
  100. package/dist/scene-camera.d.ts.map +1 -0
  101. package/dist/scene-camera.js +130 -0
  102. package/dist/scene-camera.js.map +1 -0
  103. package/dist/scene-hand.d.ts +110 -0
  104. package/dist/scene-hand.d.ts.map +1 -0
  105. package/dist/scene-hand.js +317 -0
  106. package/dist/scene-hand.js.map +1 -0
  107. package/dist/scene-helpers.d.ts +46 -0
  108. package/dist/scene-helpers.d.ts.map +1 -0
  109. package/dist/scene-helpers.js +108 -0
  110. package/dist/scene-helpers.js.map +1 -0
  111. package/dist/scene-lines.d.ts +76 -0
  112. package/dist/scene-lines.d.ts.map +1 -0
  113. package/dist/scene-lines.js +578 -0
  114. package/dist/scene-lines.js.map +1 -0
  115. package/dist/scene-root.d.ts +71 -0
  116. package/dist/scene-root.d.ts.map +1 -0
  117. package/dist/scene-root.js +790 -0
  118. package/dist/scene-root.js.map +1 -0
  119. package/dist/scene.d.ts +17 -0
  120. package/dist/scene.d.ts.map +1 -0
  121. package/dist/scene.js +17 -0
  122. package/dist/scene.js.map +1 -0
  123. package/dist/seat-marks.d.ts +35 -0
  124. package/dist/seat-marks.d.ts.map +1 -0
  125. package/dist/seat-marks.js +43 -0
  126. package/dist/seat-marks.js.map +1 -0
  127. package/dist/settings.d.ts +30 -0
  128. package/dist/settings.d.ts.map +1 -0
  129. package/dist/settings.js +73 -0
  130. package/dist/settings.js.map +1 -0
  131. package/dist/view-boundary.d.ts +33 -0
  132. package/dist/view-boundary.d.ts.map +1 -0
  133. package/dist/view-boundary.js +53 -0
  134. package/dist/view-boundary.js.map +1 -0
  135. package/dist/view-host.d.ts +57 -0
  136. package/dist/view-host.d.ts.map +1 -0
  137. package/dist/view-host.js +474 -0
  138. package/dist/view-host.js.map +1 -0
  139. package/dist/view-registry.d.ts +122 -0
  140. package/dist/view-registry.d.ts.map +1 -0
  141. package/dist/view-registry.js +20 -0
  142. package/dist/view-registry.js.map +1 -0
  143. package/dist/where-drawn.d.ts +151 -0
  144. package/dist/where-drawn.d.ts.map +1 -0
  145. package/dist/where-drawn.js +231 -0
  146. package/dist/where-drawn.js.map +1 -0
  147. package/package.json +60 -0
@@ -0,0 +1,790 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { beginning, toIso, touchWeights } from "@graview/core";
3
+ import { aggregateId, cameraLimit, panForZoom, kindOfCard, kindsOf, layout, withFocus, withOverview, withPan, withPin, panLayout, withRelation, withJackIn, holdLayout, isBandAggregate, toggleExpanded, withWithin, } from "@graview/layout";
4
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
5
+ import { useActivity } from "./activity.js";
6
+ import { useAnimatedLayout, useSeatWork, useTouched } from "./animation.js";
7
+ import { SeatMarks } from "./seat-marks.js";
8
+ import { useViolations } from "./hooks.js";
9
+ import { useFound, useGraph, useGraview } from "./context.js";
10
+ import { isDefaultView } from "./view-registry.js";
11
+ import { Plots } from "./plots.js";
12
+ import { Occupants } from "./occupants.js";
13
+ import { EdgeSigns } from "./edge-signs.js";
14
+ import { useCameraFlights } from "./scene-camera.js";
15
+ import { useHeldDistrict, useSceneDrag, useWheelAndPinch, useWorldShift } from "./scene-hand.js";
16
+ import { whereIsIn } from "./where-drawn.js";
17
+ import { BandCard, BeyondCard, SettledView } from "./resolved-view.js";
18
+ import { selectionFor, useElementSize, useRootUnit } from "./scene-helpers.js";
19
+ import { Lines, RelationCaptions } from "./scene-lines.js";
20
+ import { railInset } from "./rails.js";
21
+ export { railInset };
22
+ import { SceneViewHost } from "./view-host.js";
23
+ /**
24
+ * The spatial scene: one `<canvas layoutsubtree>` with the views as its
25
+ * IMMEDIATE children.
26
+ *
27
+ * That flatness is a platform constraint, not a style: capture rejects
28
+ * anything deeper than a direct child of the canvas. Nesting happens inside a
29
+ * view, never between views.
30
+ */
31
+ /**
32
+ * How far a pointer must travel before it is a drag rather than a click.
33
+ * Below this nothing has moved and the gesture is an ordinary selection.
34
+ */
35
+ export function Scene({ options, renderer = "auto", attachRenderer, className, style, animate = true, children, }) {
36
+ const { store, scheme, views, view, setView, selection, setSelection, setMenuAt, emphasis, hiddenKinds, registerScene, pointer, brand, noteMoved, robots } = useGraview();
37
+ const found = useFound();
38
+ const canvasRef = useRef(null);
39
+ const wrapperRef = useRef(null);
40
+ const size = useElementSize(wrapperRef);
41
+ const unit = useRootUnit();
42
+ // Pinch and ctrl+wheel: altitude from the ground, zoom about the pointer from above; the plain wheel pans. See scene-hand.ts.
43
+ const { zoomAbout, panBy } = useWheelAndPinch({ stage: wrapperRef, view, setView });
44
+ // `nodes` is a cached snapshot that only changes when the graph does, so
45
+ // the layout is recomputed exactly when the picture could have changed.
46
+ const nodes = useGraph();
47
+ // What each subject's violations name, so a rule's neighbourhood is what it judges.
48
+ const violations = useViolations();
49
+ const judged = useMemo(() => {
50
+ const map = {};
51
+ for (const violation of violations) {
52
+ if (violation.subjectId === undefined)
53
+ continue;
54
+ const list = (map[violation.subjectId] ??= []);
55
+ for (const id of violation.nodeIds)
56
+ if (id !== violation.subjectId && !list.includes(id))
57
+ list.push(id);
58
+ }
59
+ return map;
60
+ }, [violations]);
61
+ // The scene is laid out to the space it actually has. A fixed canvas leaves
62
+ // dead ground on a wide screen and clips on a narrow one, and the plane
63
+ // bands are proportions rather than pixels, so they follow.
64
+ /*
65
+ * FLYING CLOSER. Choosing a picture from altitude brings the camera in:
66
+ * the city's cell grows, the villages and roads with it, the billboard
67
+ * bigger on its plot, and the camera keeps the picture in view. Derived
68
+ * from the stop, never stored: leaving the picture flies back out.
69
+ */
70
+ const closer = (view.overview ?? false) && view.within?.["view"] !== undefined ? 1.5 : 1;
71
+ /*
72
+ * ZOOM BY HAND. The fly-closer step above is the scene's own; this is the
73
+ * person's, changed continuously by pinch and ctrl+wheel about the
74
+ * pointer and by the controls in the ground's corner, multiplied in.
75
+ * Scene state like the camera, never the URL: an address says where you
76
+ * are, not how close you are standing. Reset on the way down — on the
77
+ * ground it means nothing, and rising again starts level.
78
+ */
79
+ const [zoom, setZoom] = useState(1);
80
+ const zoomLive = useRef(1);
81
+ useEffect(() => {
82
+ if (view.overview)
83
+ return;
84
+ zoomLive.current = 1;
85
+ setZoom(1);
86
+ }, [view.overview]);
87
+ const cityZoom = closer * zoom;
88
+ /*
89
+ * THE BILLBOARD IS CUT TO ITS PICTURE. The lens draws in a box as tall as
90
+ * the window; the screen's host reports how much of it the lens actually
91
+ * used, and the layout sizes the billboard to that — so the picture's foot
92
+ * is on the kerb instead of a village's height above it. Whole pixels,
93
+ * and only a change re-lays the city.
94
+ */
95
+ /*
96
+ * Reported by the layout's CURRENT screen only. A billboard on its way
97
+ * into its village is still drawn as one, but for the length of the tween
98
+ * two hosts would report, the layout would flip between their two heights,
99
+ * and every flip restarted the tween from where it was — a crawl of a
100
+ * pixel a frame until the old picture had faded.
101
+ */
102
+ const [screenHeight, setScreenHeight] = useState(undefined);
103
+ const noteScreenHeight = useCallback((height) => {
104
+ setScreenHeight((current) => {
105
+ const next = height === undefined ? undefined : Math.round(height);
106
+ return current === next ? current : next;
107
+ });
108
+ }, []);
109
+ /*
110
+ * WHAT STANDS AS ITSELF when the band cannot hold a relation: the search's
111
+ * hits, the flagged, the recently written — the selection the layout reads
112
+ * from the stop. Each only changes with the graph or the words.
113
+ */
114
+ const relevance = useMemo(() => ({
115
+ hits: new Set(found?.matched ?? []),
116
+ flagged: new Set(violations.flatMap((violation) => violation.nodeIds)),
117
+ touched: touchWeights(store.log.all()),
118
+ }),
119
+ // `nodes` is the graph's tick: the log only grows when the graph changes.
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ [found, violations, store, nodes]);
122
+ const sized = useMemo(() => ({
123
+ ...options,
124
+ cityZoom,
125
+ ...(screenHeight !== undefined ? { screenHeight } : {}),
126
+ // What is not drawn for this seat at this stop: a workspace's disabled
127
+ // modules, and the administered ones this seat may not see or has not
128
+ // asked to — one set, from the provider, so every surface agrees.
129
+ ...(hiddenKinds.size > 0 ? { hiddenKinds: [...hiddenKinds].sort() } : {}),
130
+ ...(Object.keys(judged).length > 0 ? { judged } : {}),
131
+ // What stands as itself when a relation does not fit the band (docs/scale.md).
132
+ relevance,
133
+ /*
134
+ * THE LEFT RAIL. The relation key, the quick relations and the
135
+ * inspector live on the scene's left edge in every mode, and the
136
+ * picture used to run under them — a district beneath the pane at
137
+ * altitude, a lens's title under the quick relations in focus. The
138
+ * layout keeps every card to what is left. The altitude control sits
139
+ * in the top-right corner, where a full-width focus card's own corner
140
+ * used to be — so the right has a rail too.
141
+ */
142
+ // In proportion: an embed a paragraph wide cannot give a third of
143
+ // itself to chrome. From the default 1200 up these are 264 and 128.
144
+ //
145
+ // And below a phone's width there is no rail at all: the companion
146
+ // is a sheet at the foot, the inspector a sheet over the picture, and
147
+ // the only thing on the right is the altitude control's corner. A
148
+ // 360px frame that kept a fifth of itself for panes nobody drew there
149
+ // gave a focused lens 145 pixels, which is not a lens, it is a spine.
150
+ inset: railInset(size?.width ?? 1200),
151
+ // The reader's own text size, which the cards are sized in: the city
152
+ // grows with the words rather than holding them at a fixed 230×97.
153
+ unit,
154
+ // Groups the framework's own list shows: from altitude those are
155
+ // districts, not scaled cards. A group with an app's view keeps its card.
156
+ plainGroups: store.schema.kinds.filter((kind) => isDefaultView(views.lookup(kind, { cardinality: "many", fidelity: "full" }))),
157
+ /*
158
+ * THE ORDER THE CITY IS WALKED IN: the chain a blank installation fills
159
+ * its kinds in, read from the store's own acts. The declaration's
160
+ * order, so the map is the declaration's map.
161
+ */
162
+ cityOrder: beginning({
163
+ name: "scene",
164
+ schema: store.schema,
165
+ mutations: store.allMutations().filter((mutation) => !mutation.derived),
166
+ }).order.map((entry) => entry.kind),
167
+ /*
168
+ * THE SHOWINGS, by kind: the named places the registry holds, so a
169
+ * focused picture stands on its kind's plot as a screen from altitude.
170
+ */
171
+ screens: views.places().reduce((held, place) => {
172
+ (held[place.kind] ??= []).push({ as: place.as, title: place.title, ...(place.across ? { across: place.across } : {}) });
173
+ return held;
174
+ }, {}),
175
+ ...(size
176
+ ? {
177
+ width: size.width,
178
+ /*
179
+ * The scene lays out into its WHOLE box. The actions strip is a
180
+ * transient elevated surface — it floats in front of the scene
181
+ * the way a menu floats in front of a page, and reserving a
182
+ * permanent band of the height for it squeezed every band on
183
+ * every screen for chrome that mostly is not there.
184
+ */
185
+ height: size.height,
186
+ }
187
+ : {}),
188
+ }), [options, size, unit, store, views, hiddenKinds, judged, relevance, cityZoom, screenHeight]);
189
+ /*
190
+ * THE CAMERA IS NOT A MOVE. A drive-in on the far side of a large city
191
+ * lights up off-screen unless the camera goes to it, and the camera's
192
+ * own offset is derived from the focus rather than made by a hand — so
193
+ * it lives here, added to whatever the person panned, and never in the
194
+ * URL. "Put it back" then clears the person's pan and leaves the camera
195
+ * on the screen, which is what putting it back means.
196
+ */
197
+ const [camera, setCamera] = useState({ x: 0, y: 0 });
198
+ const panned = useMemo(() => ({ x: (view.pan?.x ?? 0) + camera.x, y: (view.pan?.y ?? 0) + camera.y }), [view.pan, camera]);
199
+ const seen = useMemo(() => (camera.x === 0 && camera.y === 0 ? view : withPan(view, panned)), [view, camera, panned]);
200
+ /*
201
+ * THE WHOLE OFFSET IS WHAT IS CLAMPED. The person's pan and the camera's
202
+ * flight add up to where the city is; clamping the pan alone let the
203
+ * flight to a far village eat the room to pan back, and the far side of
204
+ * a flown-closer city could not be reached. Pan plus camera stays within
205
+ * the camera limit — a little way over a picture that fits, as far as the
206
+ * city reaches when it is bigger than the window — and every district is
207
+ * reachable.
208
+ */
209
+ const cameraLive = useRef(camera);
210
+ cameraLive.current = camera;
211
+ const panWithin = useCallback((limit, wanted) => {
212
+ const cam = cameraLive.current;
213
+ /*
214
+ * AND THE CAMERA'S OWN FLIGHT IS INSIDE THE LIMIT, WHEREVER IT WENT.
215
+ *
216
+ * Flying closer to a picture takes the camera past what the limit
217
+ * allows on purpose — the billboard stands above the city's extent,
218
+ * and the limit does not know about it. Clamping the total offset to
219
+ * that limit afterwards meant every drag resolved to the same
220
+ * clamped number: the ground would not move at all once a lens had
221
+ * been chosen, which is the picture refusing to be looked around.
222
+ *
223
+ * The interval is the limit OR the camera, whichever reaches further
224
+ * — so at rest this is exactly the old rule, and after a flight you
225
+ * can pan back over the city and as far as the flight itself went,
226
+ * but never further out than either.
227
+ */
228
+ const room = (bound, at) => ({ low: Math.min(-bound, at), high: Math.max(bound, at) });
229
+ const across = room(limit.x, cam.x);
230
+ const down = room(limit.y, cam.y);
231
+ return {
232
+ x: Math.max(across.low, Math.min(across.high, wanted.x + cam.x)) - cam.x,
233
+ y: Math.max(down.low, Math.min(down.high, wanted.y + cam.y)) - cam.y,
234
+ };
235
+ }, []);
236
+ const pinnedIds = useMemo(() => new Set(Object.keys(view.pins)), [view.pins]);
237
+ // A district under the hand is placed here while it is dragged, and written into the view on release.
238
+ const district = useHeldDistrict();
239
+ const held = district.held;
240
+ /*
241
+ * THE WORLD IS LAID OUT AT REST, AND THEN MOVED.
242
+ *
243
+ * `layout` bakes the pan into every coordinate, which is what lets two
244
+ * layouts be interpolated into motion — but it does that at the very end,
245
+ * after it has decided communities, plots, band packing and the drive-in's
246
+ * own sizing loop. A drag changes nothing but the pan, and this memo was
247
+ * keyed on a view that carried it, so every pointer move ran all of that
248
+ * again to reach an answer that differed from the last one by a
249
+ * subtraction. Measured on rota's city: 169 of 467 frames dropped and ten
250
+ * seconds of blocked main thread in a two-second drag.
251
+ *
252
+ * So the pan comes off the key. The expensive half is held still while a
253
+ * hand is moving, and `panLayout` puts the world where the hand has taken
254
+ * it — the same object, held to that by `the-pan-is-a-translation`.
255
+ */
256
+ /*
257
+ * Keyed on the VIEW, not on the view-plus-camera: the camera is an offset
258
+ * the scene makes for itself — flying to a drive-in, landing a descent —
259
+ * and it is the same kind of thing as a pan. Keying this on `seen` meant a
260
+ * camera that moved by a pixel rebuilt the world exactly as a pan did.
261
+ */
262
+ /*
263
+ * A CARD UNDER THE HAND IS MOVED, NOT LAID OUT (docs/scale.md). In the
264
+ * stack a held card is the only thing a drag changes, so the world is laid
265
+ * out without it and `holdLayout` moves it — a full layout per pointer
266
+ * move was the band's grouping and every edge's bundle, every frame. A
267
+ * district held at altitude carries its plot and village with it, which
268
+ * only the layout knows, so up there the pin still goes into the layout.
269
+ */
270
+ const inPlace = held !== null && !(view.overview ?? false);
271
+ // Only a pin the layout needs is a dependency: a card moved in place must not re-lay the stop.
272
+ const pinned = inPlace ? null : held;
273
+ const atRest = useMemo(() => {
274
+ const still = view.pan ? { ...view, pan: undefined } : view;
275
+ return pinned ? withPin(still, pinned.id, { x: pinned.x, y: pinned.y }) : still;
276
+ }, [view, pinned]);
277
+ const laid = useMemo(() => layout(store.graph, store.schema, atRest, sized), [store, atRest, sized, nodes]);
278
+ const still = useMemo(() => held && inPlace
279
+ ? (holdLayout(laid, held.id, { x: held.x, y: held.y }) ??
280
+ layout(store.graph, store.schema, withPin(atRest, held.id, { x: held.x, y: held.y }), sized))
281
+ : laid, [laid, held, inPlace, store, atRest, sized]);
282
+ const result = useMemo(() => panLayout(still, seen.pan ?? { x: 0, y: 0 }), [still, seen.pan]);
283
+ const ZOOM_MIN = 0.6;
284
+ const ZOOM_MAX = 3;
285
+ zoomAbout.current = (factor, clientX, clientY) => {
286
+ if (!(view.overview ?? false))
287
+ return;
288
+ /*
289
+ * A ZOOM WRITES STATE, so any pan the wheel is still holding has to
290
+ * land first — zoom and pan share `steer`, and a zoom on top of an
291
+ * uncommitted offset would compute from a pan the view does not have.
292
+ */
293
+ settleLive.current();
294
+ const current = zoomLive.current;
295
+ const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current * factor));
296
+ if (Math.abs(next - current) < 1e-4)
297
+ return;
298
+ zoomLive.current = next;
299
+ steer();
300
+ setZoom(next);
301
+ const box = wrapperRef.current?.getBoundingClientRect();
302
+ const centre = { x: result.width / 2, y: result.height / 2 };
303
+ const pointer = box && clientX !== undefined && clientY !== undefined ? { x: clientX - box.left, y: clientY - box.top } : centre;
304
+ const ratio = next / current;
305
+ setView((current) => withPan(current, panForZoom(current.pan ?? { x: 0, y: 0 }, cameraLive.current, pointer, centre, ratio)));
306
+ noteMoved();
307
+ };
308
+ panBy.current = (dx, dy) => {
309
+ steer();
310
+ const limit = cameraLimit(result);
311
+ const pan = view.pan ?? { x: 0, y: 0 };
312
+ if (useDom) {
313
+ /*
314
+ * THE WHEEL IS A HAND TOO — so it moves the picture, and only the
315
+ * wheel coming to rest moves the world. It was left on the old road
316
+ * when the drag came off it: a `setView` per tick, which is a layout,
317
+ * a render of every context consumer and a DOM re-measure for each
318
+ * notch of a wheel.
319
+ */
320
+ const live = shift.offset();
321
+ shift.shiftTo(panWithin(limit, { x: pan.x + live.x + dx, y: pan.y + live.y + dy }), pan);
322
+ }
323
+ else {
324
+ setView((current) => withPan(current, panWithin(limit, { x: (current.pan?.x ?? 0) + dx, y: (current.pan?.y ?? 0) + dy })));
325
+ }
326
+ noteMoved();
327
+ };
328
+ /*
329
+ * A DRAG IS NOT A TRANSITION.
330
+ *
331
+ * Every pointer move writes a new view state, and easing toward each one
332
+ * over half a second made the scene chase the pointer — panning felt
333
+ * laggy because it literally lagged, by design meant for navigation. While
334
+ * a drag owns the pointer the picture snaps to it; the tween is for the
335
+ * moves you did not make with your own hand.
336
+ */
337
+ const [dragging, setDragging] = useState(false);
338
+ /*
339
+ * A WHEEL IS A HAND TOO. Zooming and panning by wheel arrive as a stream
340
+ * of small moves; tweening each one lagged the picture behind the fingers
341
+ * the way a tweened drag did. While the wheel is turning, and for a beat
342
+ * after, the picture snaps to it.
343
+ */
344
+ const [steering, setSteering] = useState(false);
345
+ const steeringUntil = useRef(null);
346
+ /*
347
+ * A WHEEL LETS GO TOO, and this is how it says so.
348
+ *
349
+ * The live-shift machinery is declared below — it needs the layout, which
350
+ * needs the view — and `steer` is needed above it, by the wheel and the
351
+ * zoom. A ref rather than a reordering: moving the declaration would drag
352
+ * `steering` and the tween's own `enabled` down with it, which is three
353
+ * hundred lines of unrelated motion for one call.
354
+ */
355
+ const settleLive = useRef(() => false);
356
+ const steer = useCallback(() => {
357
+ setSteering(true);
358
+ if (steeringUntil.current)
359
+ clearTimeout(steeringUntil.current);
360
+ steeringUntil.current = setTimeout(() => {
361
+ steeringUntil.current = null;
362
+ /*
363
+ * The wheel stops steering when the pan it made has been DRAWN, not
364
+ * when the wheel stops turning — clearing it here would re-enable the
365
+ * tween in the same breath as the commit, and the city would fly from
366
+ * where the wheel left it back to where it started. The same trap the
367
+ * drag fell into; the layout effect closes both.
368
+ */
369
+ if (settleLive.current())
370
+ return;
371
+ setSteering(false);
372
+ }, 160);
373
+ }, []);
374
+ // The picture as it is right now, part-way between the last view and this
375
+ // one. Everything downstream draws the tween, not the destination.
376
+ const frame = useAnimatedLayout(result, { enabled: animate && !dragging && !steering });
377
+ // Whether anything is moving: the hand, the wheel, or a transition not yet landed.
378
+ const { motion } = useGraview();
379
+ // `t` is the tween's progress: a landed frame is at 1 (the frame is always a fresh object, never `result`).
380
+ useEffect(() => motion.set(dragging || steering || frame.t < 1), [motion, dragging, steering, frame]);
381
+ const touched = useTouched();
382
+ const seatWork = useSeatWork();
383
+ // Where the camera goes on its own: to a drive-in off the edge, and down into a village. See scene-camera.ts.
384
+ const screenId = result.nodes.find((node) => node.screenOf !== undefined)?.id ?? null;
385
+ const flights = useCameraFlights({ view, result, frame, panned, closer, screenId, setCamera });
386
+ /*
387
+ * WHERE IS: the scene lends the context its live frame. A ref, so the
388
+ * answer is the frame being drawn right now — mid-tween, mid-pan — and
389
+ * asking costs nobody a render.
390
+ */
391
+ const frameRef = useRef(frame);
392
+ frameRef.current = frame;
393
+ useEffect(() => {
394
+ registerScene({
395
+ whereIs: (id) => whereIsIn(frameRef.current, wrapperRef.current, scheme, views, id),
396
+ });
397
+ return () => registerScene(null);
398
+ }, [registerScene, scheme, views]);
399
+ /*
400
+ * THE POINTER, only while somebody is listening. The store tells the
401
+ * scene when its first subscriber arrives and its last leaves; between
402
+ * those two moments there is a listener, and outside them there is none.
403
+ */
404
+ useEffect(() => pointer.onActive((active) => {
405
+ const element = wrapperRef.current;
406
+ if (!element)
407
+ return;
408
+ const move = (event) => {
409
+ const rect = element.getBoundingClientRect();
410
+ pointer.set({ x: event.clientX - rect.left, y: event.clientY - rect.top });
411
+ };
412
+ const leave = () => pointer.set(null);
413
+ if (active) {
414
+ element.addEventListener("pointermove", move);
415
+ element.addEventListener("pointerleave", leave);
416
+ element.__graviewPointer = () => {
417
+ element.removeEventListener("pointermove", move);
418
+ element.removeEventListener("pointerleave", leave);
419
+ };
420
+ }
421
+ else {
422
+ element.__graviewPointer?.();
423
+ delete element.__graviewPointer;
424
+ }
425
+ }), [pointer]);
426
+ useEffect(() => {
427
+ if (renderer === "dom")
428
+ return;
429
+ const canvas = canvasRef.current;
430
+ if (!canvas || !attachRenderer)
431
+ return;
432
+ // The renderer is handed the FRAME, not the target.
433
+ //
434
+ // Two reasons, both of which were bugs before: the DOM holds the tween,
435
+ // so a host for a node that has not entered yet does not exist to be
436
+ // captured; and drawing the target during a transition would snap every
437
+ // view to its final position while the DOM animated underneath it.
438
+ // A group is stale when any of its members changed, since its own view
439
+ // is a summary of them.
440
+ const dirty = new Set();
441
+ for (const node of frame.nodes) {
442
+ if (touched.has(node.id))
443
+ dirty.add(node.id);
444
+ else if (node.aggregate?.memberIds.some((id) => touched.has(id)))
445
+ dirty.add(node.id);
446
+ }
447
+ const detach = attachRenderer({
448
+ canvas,
449
+ layout: frame,
450
+ dirty,
451
+ hostOf: (id) => canvas.querySelector(`[data-graview-view="${CSS.escape(id)}"]`),
452
+ });
453
+ return () => {
454
+ detach?.();
455
+ };
456
+ }, [renderer, attachRenderer, frame, touched]);
457
+ const useDom = renderer === "dom" || (renderer === "auto" && !attachRenderer);
458
+ /*
459
+ * What just happened, resolved onto whatever is DRAWN.
460
+ *
461
+ * The op log names node ids, and a node is not always on screen as itself:
462
+ * above the stack a duty is inside the Runs card, and inside the stack it
463
+ * may be its own panel. An edit should land on whichever of those the eye
464
+ * can actually see, which is the same rule the connectors follow — the
465
+ * node if it is placed, otherwise the group standing in for it.
466
+ */
467
+ const activity = useActivity();
468
+ const activityOf = (node) => {
469
+ const own = node ? activity.get(node.id) : undefined;
470
+ const members = node?.aggregate?.memberIds ?? [];
471
+ if (!own && members.length === 0)
472
+ return undefined;
473
+ let best = own;
474
+ let wrote = own?.wrote ?? false;
475
+ let read = own?.read ?? false;
476
+ let broke = own?.broke ?? false;
477
+ for (const memberId of members) {
478
+ const mark = activity.get(memberId);
479
+ if (!mark)
480
+ continue;
481
+ // A card standing for forty nodes reports the strongest thing that
482
+ // happened inside it, not the last one alphabetically — and a rule
483
+ // that broke in there is reported whichever member it landed on,
484
+ // because that is the news.
485
+ wrote ||= mark.wrote;
486
+ read ||= mark.read;
487
+ broke ||= mark.broke;
488
+ if (!best || mark.at > best.at || (mark.at === best.at && mark.wrote && !best.wrote)) {
489
+ best = mark;
490
+ }
491
+ }
492
+ return best ? { ...best, wrote, read, broke } : undefined;
493
+ };
494
+ /* THE HAND: dragging the ground to look around and a district to place it — see scene-hand.ts. */
495
+ const shift = useWorldShift({
496
+ stage: wrapperRef,
497
+ pan: view.pan,
498
+ setView,
499
+ // The gesture ends with the world already where the hand left it, so nothing tweens there afterwards.
500
+ settled: () => {
501
+ setDragging(false);
502
+ setSteering(false);
503
+ },
504
+ });
505
+ // Wired here, where the shift exists; see the ref's own note above.
506
+ settleLive.current = shift.settle;
507
+ const { onGroundDown, onCardDown, onDragMove, onDragUp, swallow } = useSceneDrag({
508
+ view,
509
+ panned,
510
+ limit: () => cameraLimit(result),
511
+ panWithin,
512
+ moveByTransform: useDom,
513
+ setView,
514
+ setDragging,
515
+ noteMoved,
516
+ shift,
517
+ district,
518
+ });
519
+ /*
520
+ * A CROWD drops to glyphs. A summary panel needs room, and a raised
521
+ * relation with many members divides the band until no card has any —
522
+ * ten titles wrapping to five lines in 130-pixel slivers. Below the
523
+ * legibility floor a card renders the kind's GLYPH instead, which is
524
+ * what the fidelity axis is for: legible at any width, still selectable,
525
+ * still the node. A band that WRAPPED into rows is a crowd by height: a
526
+ * summary card in a 46-pixel row showed its title cut at the second line.
527
+ *
528
+ * The floor is what a summary holds: its title and one row of chips, 80
529
+ * pixels. At 64 a release in a three-row band drew its name and a sliver
530
+ * of its date chip under a fade — a card that looked broken, where a chip
531
+ * would have read.
532
+ */
533
+ const SUMMARY_FLOOR = 80;
534
+ const crowded = (node) => Math.round(node.plane) === 1 && (node.compact === true || node.width < 175 || node.height < SUMMARY_FLOOR);
535
+ /*
536
+ * Whether this card stands for a kind the app gave a picture of its own —
537
+ * the same question `ResolvedView` asks to draw the ◆, asked here so the
538
+ * gesture and the mark cannot disagree.
539
+ */
540
+ const ownPictureOf = (node) => {
541
+ if (kindOfCard(node.id) === null && !node.aggregate)
542
+ return false;
543
+ const own = views.resolve(node.kind, { cardinality: "many", fidelity: "full" })?.view;
544
+ return own !== undefined && own.generic !== true;
545
+ };
546
+ const hosts = frame.nodes.map((node) => (_jsxs(SceneViewHost, { node: node, crowded: crowded(node), useDom: useDom, ...(node.plot && frame.city && Math.round(node.plane) === 2
547
+ ? {
548
+ frontY: frame.city.originY + panned.y + toIso(node.plot.col + node.plot.side / 2, node.plot.row + node.plot.side, frame.city.cell).y - node.y,
549
+ centreY: frame.city.originY + panned.y + toIso(node.plot.col + node.plot.side / 2, node.plot.row + node.plot.side / 2, frame.city.cell).y - node.y,
550
+ }
551
+ : {}), ...(node.screenOf !== undefined ? { screen: true, ...(node.id === screenId ? { onDrawnHeight: noteScreenHeight } : {}) } : {}), touched: touched.has(node.id), ...(activityOf(node) ? { activity: activityOf(node) } : {}), scheme: scheme, canvasWidth: result.width, canvasHeight: result.height, selected: selection.includes(node.id), onSelect: (additive) => {
552
+ /*
553
+ * A group is a PLACE; a node is a THING.
554
+ *
555
+ * Clicking a group raises its members onto plane 1 — which is what
556
+ * "open the People block" obviously means, and what the whole
557
+ * aggregate model is for. Before this, clicking a group silently
558
+ * selected members that were not on screen and looked like nothing
559
+ * had happened, and the only way to raise anything was a button in
560
+ * the far corner of the command bar.
561
+ *
562
+ * Hold shift or meta to select the members instead.
563
+ */
564
+ /*
565
+ * Above the stack, raising a relation means nothing.
566
+ *
567
+ * `withRelation` moves a kind's members onto plane 1, and the overview
568
+ * has no plane 1 — `relatedNodes` is hard-coded empty up there. So the
569
+ * gesture did nothing at all, and the emphasis this comment promised
570
+ * had no way to be triggered except through the legend. Selecting the
571
+ * card is what "what does this touch" means when the cards are kinds.
572
+ */
573
+ // A band's group is selected as itself: its members could be hundreds of ids in the address.
574
+ if (isBandAggregate(node.id)) {
575
+ setSelection((current) => (additive ? (current.includes(node.id) ? current.filter((id) => id !== node.id) : [...current, node.id]) : [node.id]));
576
+ return;
577
+ }
578
+ const kinds = node.aggregate ? kindsOf(node.id) : [];
579
+ if (view.overview) {
580
+ setSelection((current) => (additive ? [...new Set([...current, node.id])] : [node.id]));
581
+ return;
582
+ }
583
+ if (kinds.length === 1 && !additive && Math.round(node.plane) !== 0) {
584
+ const kind = kinds[0];
585
+ setView((current) => withRelation(current, current.relation === kind ? null : kind));
586
+ return;
587
+ }
588
+ setSelection((current) => selectionFor(node, current, additive));
589
+ },
590
+ /*
591
+ * Picking a thing SELECTS it, and leaves the picture where it is.
592
+ *
593
+ * It used to travel, which is the wrong default: most of the time you
594
+ * want to act on the thing where it is — substitute a player without
595
+ * leaving the formation, move an event without leaving the week — and
596
+ * being thrown into a detail view to do it costs you the context that
597
+ * made the decision obvious. Travel is the deliberate second gesture.
598
+ */
599
+ onPick: (id, additive) => setSelection((current) => additive
600
+ ? current.includes(id)
601
+ ? current.filter((other) => other !== id)
602
+ : [...current, id]
603
+ : [id]),
604
+ /*
605
+ * Double click means GO DEEPER, whatever it lands on: into the node a
606
+ * view nominated, or — where a view nominated nothing — into the view
607
+ * itself as a full page. One gesture, one meaning.
608
+ */
609
+ onTravel: (id) => {
610
+ setView((current) => ({ ...withFocus(current, id), relation: null }));
611
+ setSelection([id]);
612
+ }, onMenu: setMenuAt, selection: selection,
613
+ /*
614
+ * Jacking in ZOOMS: the same scene, the focus grown to most of it,
615
+ * shelf and relations receded but present. On the node already zoomed
616
+ * the same gesture zooms back out — in and out are one motion.
617
+ */
618
+ onJackIn: () => {
619
+ /*
620
+ * A BAND'S GROUP OPENS: in place, as the `expanded` stop Back undoes;
621
+ * "+N more" through to the kind's picture, filtered by the relation,
622
+ * where the row and the search make any number of them browsable.
623
+ */
624
+ const opens = node.aggregate?.opens;
625
+ if (opens) {
626
+ if (opens.in === "place")
627
+ setView((current) => toggleExpanded(current, node.id));
628
+ else
629
+ setView((current) => Object.entries(opens.within).reduce((stop, [key, value]) => withWithin(stop, key, value), withFocus(withOverview(current, false), opens.focus)));
630
+ setSelection([]);
631
+ return;
632
+ }
633
+ /*
634
+ * A kind with a lens over it goes INTO the lens; a kind without one
635
+ * explodes into its district. The card already draws a ◆ when it has
636
+ * a picture of its own, and used to burst into a ring of chips
637
+ * anyway — trading the designed view for the fallback it exists to
638
+ * improve on.
639
+ */
640
+ // A district the search lit opens narrowed by the same words.
641
+ const card = kindOfCard(node.id);
642
+ const lit = card !== null && (found?.byKind[card] ?? 0) > 0;
643
+ setView((current) => withJackIn(current, node.id, { ownPicture: ownPictureOf(node), ...(lit && current.q ? { carry: current.q } : {}) }));
644
+ /*
645
+ * A zoomed RECORD is selected — reading closely is when you act.
646
+ * A zoomed PLACE starts quiet: the click half of the double-click
647
+ * had just selected every member, and arriving with the whole
648
+ * population selected buries the place under its own strip.
649
+ */
650
+ setSelection(node.aggregate ? [] : [node.id]);
651
+ }, onDragStart: (event) => onCardDown(node, event), onDragMove: onDragMove, onDragEnd: onDragUp, swallowClick: swallow, children: [node.screenOf !== undefined ? (
652
+ /*
653
+ * THE RAIL YOU MOVE THE BOARD BY.
654
+ *
655
+ * Dragging the picture itself looks around the city, and must go on
656
+ * doing so: the billboard is the biggest thing on screen and the one
657
+ * most likely to be under the hand, and taking panning away from it
658
+ * was how the far side of a city became unreachable once. So the
659
+ * board gets a rail along its top edge, the way a window has a title
660
+ * bar — the picture pans, the rail moves the board, and neither
661
+ * gesture has to be discovered from the other.
662
+ *
663
+ * How far it may go is not this rail's business: `layout` leashes
664
+ * the pin to its own plot, so a hand and a pasted link are held to
665
+ * the same distance.
666
+ */
667
+ /*
668
+ * A TITLE BAR, not a grey strip with a pill floating over it: the
669
+ * picture's name on the left, the one way down on the right, and
670
+ * the whole bar the handle that moves the board.
671
+ */
672
+ _jsxs("div", { className: "graview-screen-grip", "data-graview-grip": node.id, "data-testid": "screen-grip", title: "Move this picture \u2014 it stays by its own village", children: [_jsx("span", { className: "graview-screen-title", "aria-hidden": "true", children: views.places().find((place) => place.as === view.within?.["view"] && place.kind === node.screenOf)?.title ?? "" }), _jsx("button", { type: "button", className: "graview-screen-fullscreen", "data-testid": "screen-fullscreen", title: "Open this picture on its own \u2014 leave the graview with it", onClick: (event) => {
673
+ event.stopPropagation();
674
+ setView((current) => withOverview(current, false));
675
+ }, onPointerDown: (event) => event.stopPropagation(), onDoubleClick: (event) => event.stopPropagation(), children: "Open \u2197" })] })) : null, node.beyond ? (_jsx(BeyondCard, { kinds: node.beyond })) : node.aggregate?.opens ? (_jsx(BandCard, { node: node })) : (_jsx(SettledView, { node: node, mode: "scene", selected: selection.includes(node.id), ...(crowded(node) ? { fidelity: "glyph" } : {}) }))] }, node.id)));
676
+ return (_jsxs("div", { ref: wrapperRef, className: `graview-ground${className ? ` ${className}` : ""}`, "data-graview-altitude": view.overview ? "" : undefined, "data-graview-reach": frame.city ? `${Math.round(cameraLimit(result).x)} ${Math.round(cameraLimit(result).y)}` : undefined, "data-graview-settled": frame.t >= 1 && !flights.gliding() ? "" : undefined, onPointerDown: onGroundDown, onPointerMove: onDragMove, onPointerUp: onDragUp, onPointerCancel: onDragUp,
677
+ /*
678
+ * CLICKING EMPTY GROUND puts the selection down — the gesture every
679
+ * canvas tool teaches, and the graceful half of deselection the ×
680
+ * and Escape were carrying alone. Only the bare ground: a card, a
681
+ * control or a piece of chrome keeps its own meaning, and a drag
682
+ * that ends on the ground is still a pan, not a deselection.
683
+ */
684
+ onClick: (event) => {
685
+ if (swallow.current)
686
+ return;
687
+ const target = event.target;
688
+ if (target.closest("[data-graview-view], button, aside, a, input, select"))
689
+ return;
690
+ setSelection([]);
691
+ setMenuAt(null);
692
+ }, style: {
693
+ position: "relative",
694
+ width: "100%",
695
+ height: "100%",
696
+ cursor: dragging ? "grabbing" : "grab",
697
+ touchAction: "none",
698
+ ["--graview-altitude"]: view.overview ? 1 : 0,
699
+ /*
700
+ * THE LATTICE THE CITY STANDS ON. The ground draws its diamonds at
701
+ * the cell the map was placed with, anchored where cell (0,0) meets
702
+ * the canvas — and the anchor pans with the picture, since the pan
703
+ * is baked into every coordinate — so a plot sits on a grid line a
704
+ * person can see. The kit's own size stands when no city is drawn.
705
+ */
706
+ ...(frame.city
707
+ ? {
708
+ ["--graview-lattice-cell"]: `${frame.city.cell.toFixed(2)}px`,
709
+ // The tween's own pan, not the live one: the ground moves with the cards it is under.
710
+ ["--graview-lattice-x"]: `${(frame.city.originX + frame.city.pan.x).toFixed(1)}px`,
711
+ ["--graview-lattice-y"]: `${(frame.city.originY + frame.city.pan.y).toFixed(1)}px`,
712
+ }
713
+ : {}),
714
+ ...(dragging ? { userSelect: "none" } : {}),
715
+ // The stage is sized to the measurement, but a stale measurement
716
+ // during a resize can briefly exceed it. Clipping keeps the scene
717
+ // inside its own bounds instead of pushing the page taller and
718
+ // cutting off anything floating over it.
719
+ overflow: "hidden",
720
+ ...style,
721
+ }, children: [_jsx(Plots, { frame: frame, width: result.width, height: result.height,
722
+ // The tween's own pan: the tiles, the villages and the roads move with the cards on them.
723
+ pan: frame.city ? frame.city.pan : panned, brand: brand, pinned: pinnedIds, swallowed: swallow,
724
+ // A tile is its district: focusing it means the kind's aggregate, never the card's own id.
725
+ onFocus: (id) => {
726
+ const kind = kindOfCard(id);
727
+ if (kind !== null)
728
+ setView((current) => withFocus(current, aggregateId(kind)));
729
+ } }), useDom ? (
730
+ /*
731
+ * The DOM path uses an ORDINARY container, not a capture canvas.
732
+ *
733
+ * `layoutsubtree` exists so the GPU can capture these elements, and
734
+ * it changes how the browser lays them out. Combining it with the CSS
735
+ * transforms and filters this path applies crashes the renderer
736
+ * process in Chromium 154 — silently, on first paint. Since the DOM
737
+ * path never captures anything, the canvas has no job here, and not
738
+ * creating one removes the whole interaction.
739
+ */
740
+ _jsx("div", { "data-graview-stage": "dom", "data-graview-world": "", style: {
741
+ position: "relative",
742
+ zIndex: 1,
743
+ width: result.width,
744
+ height: result.height,
745
+ overflow: "hidden",
746
+ }, children: hosts })) : (_jsx("canvas", { ref: canvasRef, "data-graview-stage": "gpu", layoutsubtree: "", width: result.width, height: result.height, style: {
747
+ display: "block",
748
+ position: "relative",
749
+ zIndex: 1,
750
+ width: result.width,
751
+ height: result.height,
752
+ }, children: hosts })), view.overview && !dragging ? (
753
+ /* What the window does not reach, named on the edge it lies past (see edge-signs.tsx). */
754
+ _jsx(EdgeSigns, { result: result, schema: store.schema, onGo: (by) => {
755
+ const limit = cameraLimit(result);
756
+ setView((current) => withPan(current, panWithin(limit, { x: (current.pan?.x ?? 0) + by.x, y: (current.pan?.y ?? 0) + by.y })));
757
+ noteMoved();
758
+ } })) : null, view.overview ? (
759
+ /* SCENE FURNITURE in the ground's other corner: the way a map carries its own zoom. */
760
+ _jsxs("div", { className: "graview-zoom", role: "group", "aria-label": "Zoom", onPointerDown: (event) => event.stopPropagation(), onClick: (event) => event.stopPropagation(), children: [_jsx("button", { type: "button", className: "graview-zoom-button", "data-testid": "zoom-out", "aria-label": "Zoom out", title: "Zoom out \u2014 or pinch, or ctrl+wheel", disabled: zoom <= ZOOM_MIN + 1e-6, onClick: () => zoomAbout.current(1 / 1.25), children: "\u2212" }), _jsxs("span", { className: "graview-zoom-level", "data-testid": "zoom-level", "aria-live": "polite", children: [Math.round(zoom * 100), "%"] }), _jsx("button", { type: "button", className: "graview-zoom-button", "data-testid": "zoom-in", "aria-label": "Zoom in", title: "Zoom in \u2014 or pinch, or ctrl+wheel", disabled: zoom >= ZOOM_MAX - 1e-6, onClick: () => zoomAbout.current(1.25), children: "+" })] })) : null, _jsx(Lines, { frame: frame, holding: inPlace, width: result.width, height: result.height, scheme: scheme, overview: view.overview ?? false, selection: selection, emphasis: emphasis, stageRef: wrapperRef, store: store, graphNodes: nodes,
761
+ /*
762
+ * A LINE IS A THING. Clicking one that stands for exactly one edge
763
+ * selects the relation itself — the inspector then says what it is
764
+ * and what may lawfully be done to it; right-click opens the same
765
+ * actions at the pointer. Bundled lines stay scenery: "some of
766
+ * these" is not an honest thing to act on.
767
+ */
768
+ onPickEdge: (edgeId, at) => {
769
+ // A pan that happened to start on a line is a pan, not a pick.
770
+ if (swallow.current)
771
+ return;
772
+ setSelection([edgeId]);
773
+ setMenuAt(at ? { ...at, on: edgeId } : null);
774
+ }, liveOf: (connector) => {
775
+ /*
776
+ * A relation PULSES where it was just made or broken.
777
+ *
778
+ * An edge write touches both of its ends, so a connector is live
779
+ * exactly when both of the things it joins were written in the same
780
+ * window — which is what making or breaking a relation looks like in
781
+ * the log, and is not what changing one node's field looks like.
782
+ */
783
+ const from = activityOf(frame.nodes.find((node) => node.id === connector.from));
784
+ const to = activityOf(frame.nodes.find((node) => node.id === connector.to));
785
+ return from?.wrote && to?.wrote ? (from.at > to.at ? from : to) : undefined;
786
+ } }), _jsx(Occupants, { width: result.width, whereIs: (id) => whereIsIn(frame, wrapperRef.current, scheme, views, id) }), _jsx(SeatMarks, { marks: seatWork.marks, questions: [...robots.values()]
787
+ .filter((one) => one.mode === "asking" && one.at !== null && one.say)
788
+ .map((one) => ({ id: one.at, who: one.who, asks: one.say })), whereIs: (id) => whereIsIn(frame, wrapperRef.current, scheme, views, id), stageRef: wrapperRef, width: result.width, height: result.height }), _jsx(RelationCaptions, { nodes: frame.nodes, moving: frame.t < 1, holding: inPlace, scheme: scheme, width: result.width, stageRef: wrapperRef }), children] }));
789
+ }
790
+ //# sourceMappingURL=scene-root.js.map