@graview/react 0.1.23 → 0.1.24
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.
- package/dist/context.d.ts +24 -13
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +4 -1
- package/dist/context.js.map +1 -1
- package/dist/scene-options.d.ts +20 -0
- package/dist/scene-options.d.ts.map +1 -0
- package/dist/scene-options.js +116 -0
- package/dist/scene-options.js.map +1 -0
- package/dist/scene-root.d.ts +8 -0
- package/dist/scene-root.d.ts.map +1 -1
- package/dist/scene-root.js +400 -457
- package/dist/scene-root.js.map +1 -1
- package/package.json +5 -5
package/dist/scene-root.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
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 (
|
|
62
|
+
if (overview)
|
|
96
63
|
return;
|
|
97
64
|
zoomLive.current = 1;
|
|
98
65
|
setZoom(1);
|
|
99
|
-
}, [
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
238
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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),
|
|
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,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
*
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
237
|
+
const first = size !== null && !measured.current;
|
|
394
238
|
useEffect(() => {
|
|
395
239
|
if (size !== null)
|
|
396
240
|
measured.current = true;
|
|
397
241
|
}, [size]);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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,
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
});
|
|
527
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
629
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* the
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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, 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,
|
|
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
|
-
|
|
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 =
|
|
813
|
-
const 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:
|
|
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:
|
|
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
|