@graview/react 0.1.23 → 0.1.25

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