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