@getnarro/atlas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/check/index.d.ts +82 -0
  4. package/dist/check/index.js +150 -0
  5. package/dist/check/index.js.map +1 -0
  6. package/dist/chunk-5SRKWTOU.js +33 -0
  7. package/dist/chunk-5SRKWTOU.js.map +1 -0
  8. package/dist/chunk-BDF2X5LC.js +28 -0
  9. package/dist/chunk-BDF2X5LC.js.map +1 -0
  10. package/dist/chunk-RHTCDWZD.js +142 -0
  11. package/dist/chunk-RHTCDWZD.js.map +1 -0
  12. package/dist/chunk-RWYOYDBD.js +75 -0
  13. package/dist/chunk-RWYOYDBD.js.map +1 -0
  14. package/dist/chunk-SD4Y4EBG.js +69 -0
  15. package/dist/chunk-SD4Y4EBG.js.map +1 -0
  16. package/dist/chunk-UTCNNNPT.js +89 -0
  17. package/dist/chunk-UTCNNNPT.js.map +1 -0
  18. package/dist/embed/narro-atlas.js +12 -0
  19. package/dist/image/index.d.ts +132 -0
  20. package/dist/image/index.js +117 -0
  21. package/dist/image/index.js.map +1 -0
  22. package/dist/index.d.ts +33 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/lod-DBuZ0MG3.d.ts +86 -0
  26. package/dist/map/index.d.ts +222 -0
  27. package/dist/map/index.js +184 -0
  28. package/dist/map/index.js.map +1 -0
  29. package/dist/path-BIxJH8g2.d.ts +101 -0
  30. package/dist/provider-CjL3i-0A.d.ts +54 -0
  31. package/dist/react/index.d.ts +183 -0
  32. package/dist/react/index.js +291 -0
  33. package/dist/react/index.js.map +1 -0
  34. package/dist/replay/index.d.ts +162 -0
  35. package/dist/replay/index.js +138 -0
  36. package/dist/replay/index.js.map +1 -0
  37. package/dist/tag-BP51cBza.d.ts +24 -0
  38. package/dist/timeline-B_dNydQA.d.ts +194 -0
  39. package/dist/video/index.d.ts +101 -0
  40. package/dist/video/index.js +91 -0
  41. package/dist/video/index.js.map +1 -0
  42. package/dist/view-BzztOpV6.d.ts +83 -0
  43. package/dist/web/embed.d.ts +59 -0
  44. package/dist/web/embed.js +192 -0
  45. package/dist/web/embed.js.map +1 -0
  46. package/dist/web/index.d.ts +89 -0
  47. package/dist/web/index.js +4 -0
  48. package/dist/web/index.js.map +1 -0
  49. package/package.json +119 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Narro contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ <!-- generated:readme-header -->
2
+ # @getnarro/atlas
3
+
4
+ A spatial canvas for Narro — a camera path as text, one pure camera function over it, and scene providers a deck, a video and a web page all render the same way
5
+
6
+ [![npm version](https://img.shields.io/npm/v/@getnarro/atlas?logo=npm&logoColor=fff&label=npm&color=cb3837)](https://www.npmjs.com/package/@getnarro/atlas)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@getnarro/atlas?label=downloads&color=1f6feb)](https://www.npmjs.com/package/@getnarro/atlas)
8
+ [![license](https://img.shields.io/npm/l/@getnarro/atlas?label=license&color=1f6feb)](https://github.com/getnarro/narro/blob/main/LICENSE)
9
+ [![CI](https://img.shields.io/github/actions/workflow/status/getnarro/narro/ci.yml?branch=main&logo=github&label=ci)](https://github.com/getnarro/narro/actions/workflows/ci.yml)
10
+
11
+ [**getnarro.com**](https://getnarro.com) · [Documentation](https://getnarro.com/docs) · [Ecosystem](#the-narro-ecosystem)
12
+ <!-- /generated:readme-header -->
13
+
14
+ A camera path as text, one pure camera function over it, and scene providers a deck, a
15
+ video and a web page all render the same way.
16
+
17
+ ````markdown
18
+ ```atlas
19
+ scene: ./architecture.svg
20
+ ---
21
+ - id: whole
22
+ at: 0, 0, 4000
23
+ - id: ingest
24
+ at: "#ingest-box"
25
+ - id: storage
26
+ at: 2600, 1200, 500
27
+ hold: 2
28
+ ```
29
+ ````
30
+
31
+ Full guide: <https://getnarro.com/docs/atlas>
32
+
33
+ ## The camera does not lurch
34
+
35
+ Every product in this field — Prezi, impress.js, Sozi, and Narro's own older
36
+ `TransformSlide` — tweens the centre and the zoom as independent curves. The *apparent*
37
+ speed that produces is not what the numbers say: it is world speed divided by the width
38
+ of the frame you see it through, so a long flight that also zooms in starts at about one
39
+ screen per second and ends at fifty. That mismatch is the mechanical cause of the
40
+ motion-sickness complaint that tops every review of the whole category.
41
+
42
+ This uses [van Wijk & Nuij's interpolation](https://vanwijk.win.tue.nl/zoompan.pdf), which
43
+ takes the path that holds perceived speed constant. It is asserted, not claimed:
44
+
45
+ ```ts
46
+ import { flight, perceivedSpeedAt } from "@getnarro/atlas";
47
+
48
+ const f = flight({ cx: 0, cy: 0, w: 4000 }, { cx: 5000, cy: 0, w: 200 });
49
+ // perceivedSpeedAt(f, t) varies by < 0.01% across t. The naive version varies by 5x.
50
+ ```
51
+
52
+ It also means a flight knows how long it should take, so `duration` is a thing you rarely
53
+ set — and when you do, `narro check` measures it against what the move actually needs.
54
+
55
+ ## Three hosts, one camera
56
+
57
+ | Host | Import | Clock | `will-change` |
58
+ | --- | --- | --- | --- |
59
+ | Deck | `@getnarro/atlas/react` | `requestAnimationFrame` | on while flying, **off on arrival** |
60
+ | Video | `@getnarro/atlas/video` | `frame / fps` — no rAF at all | off always: every frame is a deliverable |
61
+ | Checker | `@getnarro/atlas/check` | — | — |
62
+
63
+ That `will-change` switch is the whole answer to blurry zoomed text. Chrome re-rasterises
64
+ on a scale change *unless* `will-change: transform` pins the raster — so leaving it on
65
+ makes every frame cheap and permanently soft, and leaving it off re-rasters the subtree
66
+ every frame of a zoom. Switching it means the blur exists only mid-flight, when nobody is
67
+ reading, and every resting frame is sharp.
68
+
69
+ ## Rendering model
70
+
71
+ One `translate3d(…) scale(…)` on one element, per frame. Never one per node. That is one
72
+ composited layer, one style recalculation, no layout and no paint — whether the plane
73
+ holds ten nodes or a thousand.
74
+
75
+ Nodes outside the frame are not mounted, and a node can declare the band of view widths in
76
+ which it is worth reading:
77
+
78
+ ```tsx
79
+ <Atlas path={path} nodes={[
80
+ { id: "overview", rect, lod: { minWidth: 2000 } }, // a label, while pulled back
81
+ { id: "detail", rect, lod: { maxWidth: 600 } }, // a table, once close
82
+ ]} />
83
+ ```
84
+
85
+ ## Zooming into a recorded app
86
+
87
+ `@getnarro/replay` replays a recorded app as **real DOM**, so a camera over one magnifies
88
+ live text — sharp and selectable at any zoom, where a screenshot falls apart at 3×.
89
+ Waypoints name a CSS selector rather than coordinates, because coordinates over a
90
+ recording rot the moment somebody re-records:
91
+
92
+ ```ts
93
+ import { replayProvider, measureIn } from "@getnarro/atlas/replay";
94
+
95
+ replayProvider({ cast, measure: measureIn(iframe.contentDocument) });
96
+ ```
97
+
98
+ `findInCast(cast, "#publish")` answers the same question offline, which is how
99
+ `narro check` catches a selector that matches nothing before anything is rendered.
100
+
101
+ ## Reduced motion
102
+
103
+ Honoured by **cutting** between waypoints, not by hiding anything. Every waypoint stays
104
+ reachable; only the swoop goes.
105
+
106
+ ## Not a deck mode
107
+
108
+ A zooming canvas is the wrong call for most talks — constant camera movement is tiring and
109
+ disorienting, and a cut says a logical connection better than a swoop. Use it for the part
110
+ of a deck where the spatial relationship *is* the content.
111
+
112
+ <!-- generated:readme-footer -->
113
+ ## The Narro ecosystem
114
+
115
+ | Package | What it is |
116
+ | --- | --- |
117
+ | [`@getnarro/atlas`](https://www.npmjs.com/package/@getnarro/atlas) | A spatial canvas for Narro — a camera path as text, one pure camera function over it, and scene providers a deck, a video and a web page all render the same way **← you are here** |
118
+ | [`@getnarro/cli`](https://www.npmjs.com/package/@getnarro/cli) | CLI tools for creating and managing Narro presentations |
119
+ | [`@getnarro/core`](https://www.npmjs.com/package/@getnarro/core) | The React runtime for Narro presentations — Presentation, Slide, navigation, transitions, and speaker notes |
120
+ | [`@getnarro/docs`](https://www.npmjs.com/package/@getnarro/docs) | Narro's documentation as data — markdown pages, a generated component API reference, and llms.txt |
121
+ | [`@getnarro/docspack`](https://www.npmjs.com/package/@getnarro/docspack) | Narro's documentation as a docspack — version-locked chunks an agent installs once and searches offline |
122
+ | [`@getnarro/markdown`](https://www.npmjs.com/package/@getnarro/markdown) | Markdown & MDX authoring for Narro presentations |
123
+ | [`@getnarro/marketplace`](https://www.npmjs.com/package/@getnarro/marketplace) | Themes, colour schemes, and templates for Narro presentations |
124
+ | [`@getnarro/mcp-server`](https://www.npmjs.com/package/@getnarro/mcp-server) | MCP (Model Context Protocol) server for AI-assisted Narro presentation creation |
125
+ | [`@getnarro/replay`](https://www.npmjs.com/package/@getnarro/replay) | Record a web app once and replay it as real DOM — a portable cast format, an embeddable player, and a self-contained HTML page |
126
+ | [`@getnarro/shared-ui`](https://www.npmjs.com/package/@getnarro/shared-ui) | Slide components for Narro presentations — headings, text, lists, code, media, charts, and layouts |
127
+ | [`@getnarro/terminal`](https://www.npmjs.com/package/@getnarro/terminal) | Terminal playbooks for Narro — a text format for a terminal session, a screen that is a pure function of the frame number, and a renderer that draws it on a grid |
128
+ | [`@getnarro/video`](https://www.npmjs.com/package/@getnarro/video) | Frame-deterministic video framework for Narro — a brand system, a React timeline runtime, narration-derived timing, a headless-Chrome renderer, and a scrub studio |
129
+ | [`@getnarro/video-marketing`](https://www.npmjs.com/package/@getnarro/video-marketing) | Marketing scene components for @getnarro/video — lower thirds, stat counters, feature grids, pull quotes, device frames, callouts and end cards, all brand-driven and frame-deterministic |
130
+ | [`create-narro`](https://www.npmjs.com/package/create-narro) | Scaffold a new Narro presentation |
131
+
132
+ All fourteen ship from [one repository](https://github.com/getnarro/narro) and release together.
133
+
134
+ > The npm package named `narro` is unrelated to this project. Narro's packages
135
+ > are all under the `@getnarro/` scope; the CLI binary `narro` comes from
136
+ > [`@getnarro/cli`](https://www.npmjs.com/package/@getnarro/cli).
137
+
138
+ ---
139
+
140
+ [Website](https://getnarro.com) · [Documentation](https://getnarro.com/docs) · [llms.txt](https://getnarro.com/llms.txt) · [GitHub](https://github.com/getnarro/narro) · [Issues](https://github.com/getnarro/narro/issues) · [Changelog](https://github.com/getnarro/narro/blob/main/packages/atlas/CHANGELOG.md)
141
+
142
+ Released under the [MIT License](https://github.com/getnarro/narro/blob/main/LICENSE).
143
+ <!-- /generated:readme-footer -->
@@ -0,0 +1,82 @@
1
+ import { A as AtlasPath, R as ResolvedWaypoint } from '../path-BIxJH8g2.js';
2
+ import { c as Viewport, R as Rect } from '../view-BzztOpV6.js';
3
+ import { P as PlacedNode } from '../lod-DBuZ0MG3.js';
4
+
5
+ /**
6
+ * The rules a camera path can be checked against before anybody watches it.
7
+ *
8
+ * This is the part no product in the field has, because none of them has a build step.
9
+ * Prezi, Genially, impress.js and Sozi will all happily ship a path that flies to
10
+ * somewhere there is nothing, or jumps twelve times the zoom between two waypoints, or
11
+ * gives a four-second move half a second to make it in. Every one of those is decidable
12
+ * from the path and the scene bounds, with no browser and no render, and the complaint
13
+ * the second one prevents is the number-one complaint about the entire category.
14
+ *
15
+ * Pure and dependency-free, so `narro check` can import it and so can a test.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ type AtlasSeverity = "error" | "warning";
21
+ interface AtlasDiagnostic {
22
+ severity: AtlasSeverity;
23
+ /** Which rule fired. A stable name, so a deck can silence one without silencing all. */
24
+ rule: AtlasRule;
25
+ /** The waypoint or node it is about. */
26
+ subject?: string;
27
+ message: string;
28
+ }
29
+ declare const ATLAS_RULES: readonly ["waypoint-unresolved", "waypoint-duplicate-id", "waypoint-outside-scene", "waypoint-invalid", "zoom-jump-too-large", "duration-too-short", "duration-too-long", "node-never-legible", "bitmap-over-zoomed", "path-too-short"];
30
+ type AtlasRule = (typeof ATLAS_RULES)[number];
31
+ /**
32
+ * The zoom ratio between consecutive waypoints above which audiences report disorientation.
33
+ *
34
+ * The prose version of this rule is already in `transform-mode.md` — "keep `scale` within
35
+ * roughly 0.5-2" — and prose has never stopped anybody. Eight is deliberately generous:
36
+ * it passes a normal overview-to-detail move and catches the leaps.
37
+ */
38
+ declare const MAX_ZOOM_JUMP = 8;
39
+ /** Below this fraction of the flight's own suggestion, the move lurches. */
40
+ declare const MIN_DURATION_RATIO = 0.4;
41
+ /** Above this multiple of it, the camera reads as stalled. */
42
+ declare const MAX_DURATION_RATIO = 4;
43
+ /**
44
+ * A duration this large is almost certainly milliseconds typed into a seconds field.
45
+ *
46
+ * The same mistake `transform-mode.md` documented for two releases, where `duration={1000}`
47
+ * was a sixteen-minute camera move that rendered as a hang. It cost nothing to catch and
48
+ * nothing caught it.
49
+ */
50
+ declare const IMPLAUSIBLE_DURATION_SECONDS = 30;
51
+ interface CheckOptions {
52
+ /** The grid the world is measured in. @defaultValue 1920x1080 */
53
+ viewport?: Viewport;
54
+ /** The scene's extent, when it knows it. */
55
+ bounds?: Rect | null;
56
+ /** Nodes on the plane, for the legibility rule. */
57
+ nodes?: readonly PlacedNode[];
58
+ /** Waypoints whose references could not be resolved. */
59
+ unresolved?: readonly {
60
+ id: string;
61
+ ref: string;
62
+ }[];
63
+ /**
64
+ * Native pixel size of a bitmap node, keyed by node id.
65
+ *
66
+ * A 4000px screenshot placed at 400 world units is 100 MB of texture at 10x zoom and a
67
+ * blurry mess besides. Given the sizes, this catches it before the render does.
68
+ */
69
+ bitmaps?: Record<string, {
70
+ width: number;
71
+ height: number;
72
+ }>;
73
+ }
74
+ /**
75
+ * Check a resolved path.
76
+ *
77
+ * Returns everything wrong with it at once — an author fixing one waypoint wants the
78
+ * other three in the same pass, not on the next build.
79
+ */
80
+ declare function checkPath(path: AtlasPath, waypoints: readonly ResolvedWaypoint[], options?: CheckOptions): AtlasDiagnostic[];
81
+
82
+ export { ATLAS_RULES, type AtlasDiagnostic, type AtlasRule, type AtlasSeverity, type CheckOptions, IMPLAUSIBLE_DURATION_SECONDS, MAX_DURATION_RATIO, MAX_ZOOM_JUMP, MIN_DURATION_RATIO, checkPath };
@@ -0,0 +1,150 @@
1
+ import { legibleBand } from '../chunk-5SRKWTOU.js';
2
+ import { duplicateIds, zoomRatio, flight, arcFor } from '../chunk-RHTCDWZD.js';
3
+ import { isFiniteView, viewToRect, rectWithin } from '../chunk-RWYOYDBD.js';
4
+
5
+ // src/check/rules.ts
6
+ var ATLAS_RULES = [
7
+ "waypoint-unresolved",
8
+ "waypoint-duplicate-id",
9
+ "waypoint-outside-scene",
10
+ "waypoint-invalid",
11
+ "zoom-jump-too-large",
12
+ "duration-too-short",
13
+ "duration-too-long",
14
+ "node-never-legible",
15
+ "bitmap-over-zoomed",
16
+ "path-too-short"
17
+ ];
18
+ var MAX_ZOOM_JUMP = 8;
19
+ var MIN_DURATION_RATIO = 0.4;
20
+ var MAX_DURATION_RATIO = 4;
21
+ var IMPLAUSIBLE_DURATION_SECONDS = 30;
22
+ var BOUNDS_SLACK = 0.25;
23
+ var DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
24
+ function checkPath(path, waypoints, options = {}) {
25
+ const viewport = options.viewport ?? DEFAULT_VIEWPORT;
26
+ const out = [];
27
+ for (const { id, ref } of options.unresolved ?? []) {
28
+ out.push({
29
+ severity: "error",
30
+ rule: "waypoint-unresolved",
31
+ subject: id,
32
+ message: `waypoint "${id}" points at ${ref}, which matches nothing in the scene`
33
+ });
34
+ }
35
+ for (const id of duplicateIds(path)) {
36
+ out.push({
37
+ severity: "error",
38
+ rule: "waypoint-duplicate-id",
39
+ subject: id,
40
+ message: `waypoint id "${id}" is used more than once; ids are routing fragments and export names, so they have to be unique`
41
+ });
42
+ }
43
+ if (path.waypoints.length > 0 && waypoints.length < 2) {
44
+ out.push({
45
+ severity: "warning",
46
+ rule: "path-too-short",
47
+ message: waypoints.length === 0 ? "the path has no usable waypoints, so the camera never moves" : "the path has one waypoint, so the camera never moves; a canvas with nowhere to go wants an ordinary slide"
48
+ });
49
+ }
50
+ const bounds = options.bounds;
51
+ waypoints.forEach((waypoint, i) => {
52
+ if (!isFiniteView(waypoint.at)) {
53
+ out.push({
54
+ severity: "error",
55
+ rule: "waypoint-invalid",
56
+ subject: waypoint.id,
57
+ message: `waypoint "${waypoint.id}" has no renderable framing (w must be a positive number)`
58
+ });
59
+ return;
60
+ }
61
+ if (bounds) {
62
+ const frame = viewToRect(waypoint.at, viewport);
63
+ if (!rectWithin(frame, bounds, BOUNDS_SLACK)) {
64
+ out.push({
65
+ severity: "warning",
66
+ rule: "waypoint-outside-scene",
67
+ subject: waypoint.id,
68
+ message: `waypoint "${waypoint.id}" frames ${describeRect(frame)}, which is outside the scene (${describeRect(bounds)}) \u2014 the camera flies to somewhere there is nothing`
69
+ });
70
+ }
71
+ }
72
+ const previous = waypoints[i - 1];
73
+ if (!previous) return;
74
+ const ratio = zoomRatio(previous.at, waypoint.at);
75
+ if (ratio > MAX_ZOOM_JUMP) {
76
+ out.push({
77
+ severity: "warning",
78
+ rule: "zoom-jump-too-large",
79
+ subject: waypoint.id,
80
+ message: `"${previous.id}" to "${waypoint.id}" is a ${ratio.toFixed(1)}x zoom in one move; above about ${MAX_ZOOM_JUMP}x audiences report losing their place. Add a waypoint between them.`
81
+ });
82
+ }
83
+ if (waypoint.duration === void 0) return;
84
+ const suggested = flight(previous.at, waypoint.at, arcFor(path, waypoint)).suggestedDuration;
85
+ if (waypoint.duration > IMPLAUSIBLE_DURATION_SECONDS) {
86
+ out.push({
87
+ severity: "error",
88
+ rule: "duration-too-long",
89
+ subject: waypoint.id,
90
+ message: `waypoint "${waypoint.id}" has duration ${waypoint.duration}, which is ${waypoint.duration} *seconds*. Durations are seconds here. Did you mean ${waypoint.duration / 1e3}?`
91
+ });
92
+ } else if (waypoint.duration > suggested * MAX_DURATION_RATIO) {
93
+ out.push({
94
+ severity: "warning",
95
+ rule: "duration-too-long",
96
+ subject: waypoint.id,
97
+ message: `waypoint "${waypoint.id}" takes ${waypoint.duration}s for a move that wants about ${suggested.toFixed(1)}s; the camera will read as stalled`
98
+ });
99
+ } else if (waypoint.duration < suggested * MIN_DURATION_RATIO) {
100
+ out.push({
101
+ severity: "warning",
102
+ rule: "duration-too-short",
103
+ subject: waypoint.id,
104
+ message: `waypoint "${waypoint.id}" takes ${waypoint.duration}s for a move that wants about ${suggested.toFixed(1)}s; that far under, the move reads as a lurch`
105
+ });
106
+ }
107
+ });
108
+ out.push(...checkNodes(waypoints, options, viewport));
109
+ return out;
110
+ }
111
+ function checkNodes(waypoints, options, viewport) {
112
+ const out = [];
113
+ const widths = waypoints.map((w) => w.at.w);
114
+ if (widths.length === 0) return out;
115
+ for (const node of options.nodes ?? []) {
116
+ const band = legibleBand(node);
117
+ if (band && !widths.some((w) => w >= band.min && w <= band.max)) {
118
+ out.push({
119
+ severity: "warning",
120
+ rule: "node-never-legible",
121
+ subject: node.id,
122
+ message: `node "${node.id}" is only legible between view widths ${band.min} and ${formatMax(band.max)}, and no waypoint is in that range \u2014 it is in the scene and never once readable`
123
+ });
124
+ }
125
+ const bitmap = options.bitmaps?.[node.id];
126
+ if (!bitmap) continue;
127
+ const closest = Math.min(...widths);
128
+ const magnification = viewport.width / closest * (node.rect.width / viewport.width);
129
+ const shownAt = magnification === 0 ? 0 : node.rect.width * (viewport.width / closest);
130
+ if (shownAt > bitmap.width * 1.5) {
131
+ out.push({
132
+ severity: "warning",
133
+ rule: "bitmap-over-zoomed",
134
+ subject: node.id,
135
+ message: `node "${node.id}" is ${bitmap.width}px wide but is drawn at about ${Math.round(shownAt)}px at the closest waypoint; it will be visibly soft. Use a larger source, or a waypoint that does not go so close.`
136
+ });
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ function describeRect(rect) {
142
+ return `${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}x${Math.round(rect.height)}`;
143
+ }
144
+ function formatMax(max) {
145
+ return Number.isFinite(max) ? String(max) : "\u221E";
146
+ }
147
+
148
+ export { ATLAS_RULES, IMPLAUSIBLE_DURATION_SECONDS, MAX_DURATION_RATIO, MAX_ZOOM_JUMP, MIN_DURATION_RATIO, checkPath };
149
+ //# sourceMappingURL=index.js.map
150
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/check/rules.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The rules a camera path can be checked against before anybody watches it.\n *\n * This is the part no product in the field has, because none of them has a build step.\n * Prezi, Genially, impress.js and Sozi will all happily ship a path that flies to\n * somewhere there is nothing, or jumps twelve times the zoom between two waypoints, or\n * gives a four-second move half a second to make it in. Every one of those is decidable\n * from the path and the scene bounds, with no browser and no render, and the complaint\n * the second one prevents is the number-one complaint about the entire category.\n *\n * Pure and dependency-free, so `narro check` can import it and so can a test.\n *\n * @packageDocumentation\n */\n\nimport { flight, zoomRatio } from \"../camera/flight\";\nimport { type AtlasPath, arcFor, duplicateIds, type ResolvedWaypoint } from \"../camera/path\";\nimport { isFiniteView, type Rect, rectWithin, type Viewport, viewToRect } from \"../camera/view\";\nimport { legibleBand, type PlacedNode } from \"../scene/lod\";\n\nexport type AtlasSeverity = \"error\" | \"warning\";\n\nexport interface AtlasDiagnostic {\n severity: AtlasSeverity;\n /** Which rule fired. A stable name, so a deck can silence one without silencing all. */\n rule: AtlasRule;\n /** The waypoint or node it is about. */\n subject?: string;\n message: string;\n}\n\nexport const ATLAS_RULES = [\n \"waypoint-unresolved\",\n \"waypoint-duplicate-id\",\n \"waypoint-outside-scene\",\n \"waypoint-invalid\",\n \"zoom-jump-too-large\",\n \"duration-too-short\",\n \"duration-too-long\",\n \"node-never-legible\",\n \"bitmap-over-zoomed\",\n \"path-too-short\",\n] as const;\n\nexport type AtlasRule = (typeof ATLAS_RULES)[number];\n\n/**\n * The zoom ratio between consecutive waypoints above which audiences report disorientation.\n *\n * The prose version of this rule is already in `transform-mode.md` — \"keep `scale` within\n * roughly 0.5-2\" — and prose has never stopped anybody. Eight is deliberately generous:\n * it passes a normal overview-to-detail move and catches the leaps.\n */\nexport const MAX_ZOOM_JUMP = 8;\n\n/** Below this fraction of the flight's own suggestion, the move lurches. */\nexport const MIN_DURATION_RATIO = 0.4;\n\n/** Above this multiple of it, the camera reads as stalled. */\nexport const MAX_DURATION_RATIO = 4;\n\n/**\n * A duration this large is almost certainly milliseconds typed into a seconds field.\n *\n * The same mistake `transform-mode.md` documented for two releases, where `duration={1000}`\n * was a sixteen-minute camera move that rendered as a hang. It cost nothing to catch and\n * nothing caught it.\n */\nexport const IMPLAUSIBLE_DURATION_SECONDS = 30;\n\n/** How far outside the scene bounds a waypoint may reach before it is a mistake. */\nconst BOUNDS_SLACK = 0.25;\n\nexport interface CheckOptions {\n /** The grid the world is measured in. @defaultValue 1920x1080 */\n viewport?: Viewport;\n /** The scene's extent, when it knows it. */\n bounds?: Rect | null;\n /** Nodes on the plane, for the legibility rule. */\n nodes?: readonly PlacedNode[];\n /** Waypoints whose references could not be resolved. */\n unresolved?: readonly { id: string; ref: string }[];\n /**\n * Native pixel size of a bitmap node, keyed by node id.\n *\n * A 4000px screenshot placed at 400 world units is 100 MB of texture at 10x zoom and a\n * blurry mess besides. Given the sizes, this catches it before the render does.\n */\n bitmaps?: Record<string, { width: number; height: number }>;\n}\n\nconst DEFAULT_VIEWPORT: Viewport = { width: 1920, height: 1080 };\n\n/**\n * Check a resolved path.\n *\n * Returns everything wrong with it at once — an author fixing one waypoint wants the\n * other three in the same pass, not on the next build.\n */\nexport function checkPath(\n path: AtlasPath,\n waypoints: readonly ResolvedWaypoint[],\n options: CheckOptions = {},\n): AtlasDiagnostic[] {\n const viewport = options.viewport ?? DEFAULT_VIEWPORT;\n const out: AtlasDiagnostic[] = [];\n\n for (const { id, ref } of options.unresolved ?? []) {\n out.push({\n severity: \"error\",\n rule: \"waypoint-unresolved\",\n subject: id,\n message: `waypoint \"${id}\" points at ${ref}, which matches nothing in the scene`,\n });\n }\n\n for (const id of duplicateIds(path)) {\n out.push({\n severity: \"error\",\n rule: \"waypoint-duplicate-id\",\n subject: id,\n message: `waypoint id \"${id}\" is used more than once; ids are routing fragments and export names, so they have to be unique`,\n });\n }\n\n if (path.waypoints.length > 0 && waypoints.length < 2) {\n out.push({\n severity: \"warning\",\n rule: \"path-too-short\",\n message:\n waypoints.length === 0\n ? \"the path has no usable waypoints, so the camera never moves\"\n : \"the path has one waypoint, so the camera never moves; a canvas with nowhere to go wants an ordinary slide\",\n });\n }\n\n const bounds = options.bounds;\n\n waypoints.forEach((waypoint, i) => {\n if (!isFiniteView(waypoint.at)) {\n out.push({\n severity: \"error\",\n rule: \"waypoint-invalid\",\n subject: waypoint.id,\n message: `waypoint \"${waypoint.id}\" has no renderable framing (w must be a positive number)`,\n });\n return;\n }\n\n if (bounds) {\n const frame = viewToRect(waypoint.at, viewport);\n if (!rectWithin(frame, bounds, BOUNDS_SLACK)) {\n out.push({\n severity: \"warning\",\n rule: \"waypoint-outside-scene\",\n subject: waypoint.id,\n message: `waypoint \"${waypoint.id}\" frames ${describeRect(frame)}, which is outside the scene (${describeRect(bounds)}) — the camera flies to somewhere there is nothing`,\n });\n }\n }\n\n const previous = waypoints[i - 1];\n if (!previous) return;\n\n const ratio = zoomRatio(previous.at, waypoint.at);\n if (ratio > MAX_ZOOM_JUMP) {\n out.push({\n severity: \"warning\",\n rule: \"zoom-jump-too-large\",\n subject: waypoint.id,\n message: `\"${previous.id}\" to \"${waypoint.id}\" is a ${ratio.toFixed(1)}x zoom in one move; above about ${MAX_ZOOM_JUMP}x audiences report losing their place. Add a waypoint between them.`,\n });\n }\n\n if (waypoint.duration === undefined) return;\n\n const suggested = flight(previous.at, waypoint.at, arcFor(path, waypoint)).suggestedDuration;\n\n if (waypoint.duration > IMPLAUSIBLE_DURATION_SECONDS) {\n out.push({\n severity: \"error\",\n rule: \"duration-too-long\",\n subject: waypoint.id,\n message: `waypoint \"${waypoint.id}\" has duration ${waypoint.duration}, which is ${waypoint.duration} *seconds*. Durations are seconds here. Did you mean ${waypoint.duration / 1000}?`,\n });\n } else if (waypoint.duration > suggested * MAX_DURATION_RATIO) {\n out.push({\n severity: \"warning\",\n rule: \"duration-too-long\",\n subject: waypoint.id,\n message: `waypoint \"${waypoint.id}\" takes ${waypoint.duration}s for a move that wants about ${suggested.toFixed(1)}s; the camera will read as stalled`,\n });\n } else if (waypoint.duration < suggested * MIN_DURATION_RATIO) {\n out.push({\n severity: \"warning\",\n rule: \"duration-too-short\",\n subject: waypoint.id,\n message: `waypoint \"${waypoint.id}\" takes ${waypoint.duration}s for a move that wants about ${suggested.toFixed(1)}s; that far under, the move reads as a lurch`,\n });\n }\n });\n\n out.push(...checkNodes(waypoints, options, viewport));\n\n return out;\n}\n\nfunction checkNodes(\n waypoints: readonly ResolvedWaypoint[],\n options: CheckOptions,\n viewport: Viewport,\n): AtlasDiagnostic[] {\n const out: AtlasDiagnostic[] = [];\n const widths = waypoints.map((w) => w.at.w);\n if (widths.length === 0) return out;\n\n for (const node of options.nodes ?? []) {\n const band = legibleBand(node);\n if (band && !widths.some((w) => w >= band.min && w <= band.max)) {\n out.push({\n severity: \"warning\",\n rule: \"node-never-legible\",\n subject: node.id,\n message: `node \"${node.id}\" is only legible between view widths ${band.min} and ${formatMax(band.max)}, and no waypoint is in that range — it is in the scene and never once readable`,\n });\n }\n\n const bitmap = options.bitmaps?.[node.id];\n if (!bitmap) continue;\n\n // The tightest framing that shows this node tells us how far it is magnified.\n const closest = Math.min(...widths);\n const magnification = (viewport.width / closest) * (node.rect.width / viewport.width);\n const shownAt = magnification === 0 ? 0 : node.rect.width * (viewport.width / closest);\n if (shownAt > bitmap.width * 1.5) {\n out.push({\n severity: \"warning\",\n rule: \"bitmap-over-zoomed\",\n subject: node.id,\n message: `node \"${node.id}\" is ${bitmap.width}px wide but is drawn at about ${Math.round(shownAt)}px at the closest waypoint; it will be visibly soft. Use a larger source, or a waypoint that does not go so close.`,\n });\n }\n }\n\n return out;\n}\n\nfunction describeRect(rect: Rect): string {\n return `${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}x${Math.round(rect.height)}`;\n}\n\nfunction formatMax(max: number): string {\n return Number.isFinite(max) ? String(max) : \"∞\";\n}\n"]}
@@ -0,0 +1,33 @@
1
+ import { rectsIntersect, growRect, viewToRect } from './chunk-RWYOYDBD.js';
2
+
3
+ // src/scene/lod.ts
4
+ var DEFAULT_CULL_MARGIN = 0.5;
5
+ function isLegible(node, view) {
6
+ const lod = node.lod;
7
+ if (!lod) return true;
8
+ if (lod.maxWidth !== void 0 && view.w > lod.maxWidth) return false;
9
+ if (lod.minWidth !== void 0 && view.w < lod.minWidth) return false;
10
+ return true;
11
+ }
12
+ function isNear(node, view, viewport, margin = DEFAULT_CULL_MARGIN) {
13
+ return rectsIntersect(node.rect, growRect(viewToRect(view, viewport), margin));
14
+ }
15
+ function visibleNodes(nodes, view, viewport, options = {}) {
16
+ const margin = options.margin ?? DEFAULT_CULL_MARGIN;
17
+ const keep = options.keep;
18
+ return nodes.filter((node) => {
19
+ if (keep?.has(node.id)) return true;
20
+ return isLegible(node, view) && isNear(node, view, viewport, margin);
21
+ });
22
+ }
23
+ function legibleBand(node) {
24
+ if (!node.lod) return null;
25
+ return {
26
+ min: node.lod.minWidth ?? 0,
27
+ max: node.lod.maxWidth ?? Number.POSITIVE_INFINITY
28
+ };
29
+ }
30
+
31
+ export { DEFAULT_CULL_MARGIN, isLegible, isNear, legibleBand, visibleNodes };
32
+ //# sourceMappingURL=chunk-5SRKWTOU.js.map
33
+ //# sourceMappingURL=chunk-5SRKWTOU.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/scene/lod.ts"],"names":[],"mappings":";;;AAiEO,IAAM,mBAAA,GAAsB;AAG5B,SAAS,SAAA,CAAU,MAAkB,IAAA,EAAqB;AAC/D,EAAA,MAAM,MAAM,IAAA,CAAK,GAAA;AACjB,EAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,EAAA,IAAI,IAAI,QAAA,KAAa,MAAA,IAAa,KAAK,CAAA,GAAI,GAAA,CAAI,UAAU,OAAO,KAAA;AAChE,EAAA,IAAI,IAAI,QAAA,KAAa,MAAA,IAAa,KAAK,CAAA,GAAI,GAAA,CAAI,UAAU,OAAO,KAAA;AAChE,EAAA,OAAO,IAAA;AACT;AAGO,SAAS,MAAA,CACd,IAAA,EACA,IAAA,EACA,QAAA,EACA,SAAS,mBAAA,EACA;AACT,EAAA,OAAO,cAAA,CAAe,KAAK,IAAA,EAAM,QAAA,CAAS,WAAW,IAAA,EAAM,QAAQ,CAAA,EAAG,MAAM,CAAC,CAAA;AAC/E;AAeO,SAAS,aACd,KAAA,EACA,IAAA,EACA,QAAA,EACA,OAAA,GAA6B,EAAC,EAChB;AACd,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,IAAU,mBAAA;AACjC,EAAA,MAAM,OAAO,OAAA,CAAQ,IAAA;AACrB,EAAA,OAAO,KAAA,CAAM,MAAA,CAAO,CAAC,IAAA,KAAS;AAC5B,IAAA,IAAI,IAAA,EAAM,GAAA,CAAI,IAAA,CAAK,EAAE,GAAG,OAAO,IAAA;AAC/B,IAAA,OAAO,SAAA,CAAU,MAAM,IAAI,CAAA,IAAK,OAAO,IAAA,EAAM,IAAA,EAAM,UAAU,MAAM,CAAA;AAAA,EACrE,CAAC,CAAA;AACH;AAQO,SAAS,YAAY,IAAA,EAAuD;AACjF,EAAA,IAAI,CAAC,IAAA,CAAK,GAAA,EAAK,OAAO,IAAA;AACtB,EAAA,OAAO;AAAA,IACL,GAAA,EAAK,IAAA,CAAK,GAAA,CAAI,QAAA,IAAY,CAAA;AAAA,IAC1B,GAAA,EAAK,IAAA,CAAK,GAAA,CAAI,QAAA,IAAY,MAAA,CAAO;AAAA,GACnC;AACF","file":"chunk-5SRKWTOU.js","sourcesContent":["/**\n * Culling and level of detail — what makes a big canvas both readable and cheap.\n *\n * These are the same mechanism seen from two sides, which is why they live in one file.\n *\n * **Culling** is the cost story. impress.js keeps every step mounted forever, and its own\n * issue tracker carries the consequence: a deck with large images exhausts memory, and\n * the proposed fix was to limit visibility to the previous, current and next step.\n * `TransformSlide` has the same bug today. Mounting only what the camera can see is what\n * makes the cost of a scene independent of how much is in it.\n *\n * **Level of detail** is the content story, and it is the more interesting half. A node\n * declares the range of view widths in which it is worth reading, so the same place on\n * the plane can be a label at a wide framing and a full table up close. That is what\n * makes \"a UI with several points that can be checked\" work as one scene rather than as\n * a slideshow of crops — and it happens to also be what keeps the frame budget, since\n * the detailed version is never mounted while it would be illegible anyway.\n *\n * @packageDocumentation\n */\n\nimport {\n growRect,\n type Rect,\n rectsIntersect,\n type View,\n type Viewport,\n viewToRect,\n} from \"../camera/view\";\n\n/**\n * The band of view widths in which a node is worth showing.\n *\n * Both bounds are **view widths in world units**, not zoom factors — the same unit a\n * waypoint's `w` is in, so an author reads one number off a waypoint and writes it here.\n * Remember the direction: a *wider* view is zoomed *out*.\n *\n * - `maxWidth: 1000` — appears once the camera is closer than 1000 units wide. Detail.\n * - `minWidth: 2000` — disappears once the camera is closer than 2000. An overview label.\n * - both — a band, for something legible only at a middle distance.\n */\nexport interface LodRange {\n /** Hide while the view is wider than this. */\n maxWidth?: number;\n /** Hide while the view is narrower than this. */\n minWidth?: number;\n}\n\n/** Something placed on the plane. */\nexport interface PlacedNode {\n id: string;\n /** Where it sits, in world units. */\n rect: Rect;\n /** When it is worth reading. Always visible if omitted. */\n lod?: LodRange;\n}\n\n/**\n * How much scene to keep mounted beyond the edge of the frame, as a fraction of the\n * frame's own size.\n *\n * Half a screen on each side: enough that a node is already mounted and laid out before\n * it scrolls into view, so arriving at a waypoint never costs a layout, and not so much\n * that a wide framing mounts the whole plane.\n */\nexport const DEFAULT_CULL_MARGIN = 0.5;\n\n/** Is the node inside the band of view widths where it is worth reading? */\nexport function isLegible(node: PlacedNode, view: View): boolean {\n const lod = node.lod;\n if (!lod) return true;\n if (lod.maxWidth !== undefined && view.w > lod.maxWidth) return false;\n if (lod.minWidth !== undefined && view.w < lod.minWidth) return false;\n return true;\n}\n\n/** Does the node fall inside the frame, plus the margin kept mounted around it? */\nexport function isNear(\n node: PlacedNode,\n view: View,\n viewport: Viewport,\n margin = DEFAULT_CULL_MARGIN,\n): boolean {\n return rectsIntersect(node.rect, growRect(viewToRect(view, viewport), margin));\n}\n\nexport interface VisibilityOptions {\n margin?: number;\n /**\n * Nodes to keep mounted whatever the camera is doing.\n *\n * The host passes the waypoints either side of the current one: a node that is about\n * to be flown to should already be in the document, or the arrival costs a mount, a\n * layout and a paint in the one frame where somebody is looking straight at it.\n */\n keep?: ReadonlySet<string>;\n}\n\n/** Which nodes should be in the document for this framing. */\nexport function visibleNodes(\n nodes: readonly PlacedNode[],\n view: View,\n viewport: Viewport,\n options: VisibilityOptions = {},\n): PlacedNode[] {\n const margin = options.margin ?? DEFAULT_CULL_MARGIN;\n const keep = options.keep;\n return nodes.filter((node) => {\n if (keep?.has(node.id)) return true;\n return isLegible(node, view) && isNear(node, view, viewport, margin);\n });\n}\n\n/**\n * The widest and narrowest view a node is ever legible at, or null if it always is.\n *\n * The checker uses it to find a node whose band no waypoint ever enters — content that\n * is in the scene, costs bytes, and is never once readable.\n */\nexport function legibleBand(node: PlacedNode): { min: number; max: number } | null {\n if (!node.lod) return null;\n return {\n min: node.lod.minWidth ?? 0,\n max: node.lod.maxWidth ?? Number.POSITIVE_INFINITY,\n };\n}\n"]}
@@ -0,0 +1,28 @@
1
+ import { rectToView, unionRects } from './chunk-RWYOYDBD.js';
2
+
3
+ // src/scene/plane.ts
4
+ function nodeId(ref) {
5
+ return ref.startsWith("#") ? ref.slice(1) : ref;
6
+ }
7
+ function planeProvider(options = {}) {
8
+ const nodes = options.nodes ?? [];
9
+ const byId = new Map(nodes.map((node) => [node.id, node]));
10
+ const padding = options.padding ?? 0.1;
11
+ return {
12
+ name: "plane",
13
+ bounds() {
14
+ return options.bounds ?? unionRects(nodes.map((node) => node.rect));
15
+ },
16
+ resolve(ref, viewport) {
17
+ const node = byId.get(nodeId(ref));
18
+ return node ? rectToView(node.rect, viewport, padding) : null;
19
+ },
20
+ ready() {
21
+ return true;
22
+ }
23
+ };
24
+ }
25
+
26
+ export { planeProvider };
27
+ //# sourceMappingURL=chunk-BDF2X5LC.js.map
28
+ //# sourceMappingURL=chunk-BDF2X5LC.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/scene/plane.ts"],"names":[],"mappings":";;;AAiCA,SAAS,OAAO,GAAA,EAAqB;AACnC,EAAA,OAAO,IAAI,UAAA,CAAW,GAAG,IAAI,GAAA,CAAI,KAAA,CAAM,CAAC,CAAA,GAAI,GAAA;AAC9C;AAEO,SAAS,aAAA,CAAc,OAAA,GAAwB,EAAC,EAAkB;AACvE,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,KAAA,IAAS,EAAC;AAChC,EAAA,MAAM,IAAA,GAAO,IAAI,GAAA,CAAI,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,KAAS,CAAC,IAAA,CAAK,EAAA,EAAI,IAAI,CAAC,CAAC,CAAA;AACzD,EAAA,MAAM,OAAA,GAAU,QAAQ,OAAA,IAAW,GAAA;AAEnC,EAAA,OAAO;AAAA,IACL,IAAA,EAAM,OAAA;AAAA,IAEN,MAAA,GAAsB;AACpB,MAAA,OAAO,OAAA,CAAQ,UAAU,UAAA,CAAW,KAAA,CAAM,IAAI,CAAC,IAAA,KAAS,IAAA,CAAK,IAAI,CAAC,CAAA;AAAA,IACpE,CAAA;AAAA,IAEA,OAAA,CAAQ,KAAa,QAAA,EAAiC;AACpD,MAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,GAAG,CAAC,CAAA;AACjC,MAAA,OAAO,OAAO,UAAA,CAAW,IAAA,CAAK,IAAA,EAAM,QAAA,EAAU,OAAO,CAAA,GAAI,IAAA;AAAA,IAC3D,CAAA;AAAA,IAEA,KAAA,GAAiB;AAEf,MAAA,OAAO,IAAA;AAAA,IACT;AAAA,GACF;AACF","file":"chunk-BDF2X5LC.js","sourcesContent":["/**\n * The plane: content placed on a coordinate grid, in world units.\n *\n * The default scene, and the one a deck uses. It is ready the moment it exists — there\n * is nothing to fetch — which is why it is also the provider every test uses as a stand-in.\n *\n * @packageDocumentation\n */\n\nimport { type Rect, rectToView, unionRects, type View, type Viewport } from \"../camera/view\";\nimport type { PlacedNode } from \"./lod\";\nimport type { SceneProvider } from \"./provider\";\n\nexport interface PlaneOptions {\n nodes?: readonly PlacedNode[];\n /**\n * The world rectangle the scene occupies.\n *\n * Defaults to whatever the nodes cover. Set it when the plane is a fixed backdrop — a\n * diagram, an image — that waypoints should be checked against even where no node sits.\n */\n bounds?: Rect;\n /** Padding around a node when a waypoint frames it, as a fraction of its size. */\n padding?: number;\n}\n\n/**\n * A reference on a plane is a node id, optionally with a leading `#`.\n *\n * Accepting both means a plane and a replay read the same in a path: `at: \"#ingest\"`\n * works either way, and an author moving a path from one scene to the other does not\n * have to rewrite every waypoint.\n */\nfunction nodeId(ref: string): string {\n return ref.startsWith(\"#\") ? ref.slice(1) : ref;\n}\n\nexport function planeProvider(options: PlaneOptions = {}): SceneProvider {\n const nodes = options.nodes ?? [];\n const byId = new Map(nodes.map((node) => [node.id, node]));\n const padding = options.padding ?? 0.1;\n\n return {\n name: \"plane\",\n\n bounds(): Rect | null {\n return options.bounds ?? unionRects(nodes.map((node) => node.rect));\n },\n\n resolve(ref: string, viewport: Viewport): View | null {\n const node = byId.get(nodeId(ref));\n return node ? rectToView(node.rect, viewport, padding) : null;\n },\n\n ready(): boolean {\n // Nothing to load. A plane is its own content.\n return true;\n },\n };\n}\n"]}
@@ -0,0 +1,142 @@
1
+ import { normalizeView } from './chunk-RWYOYDBD.js';
2
+
3
+ // src/camera/flight.ts
4
+ var DEFAULT_RHO = Math.SQRT2;
5
+ var EPSILON_SQUARED = 1e-12;
6
+ function cosh(x) {
7
+ const e = Math.exp(x);
8
+ return (e + 1 / e) / 2;
9
+ }
10
+ function sinh(x) {
11
+ const e = Math.exp(x);
12
+ return (e - 1 / e) / 2;
13
+ }
14
+ function tanh(x) {
15
+ const e = Math.exp(2 * x);
16
+ return (e - 1) / (e + 1);
17
+ }
18
+ var SECONDS_PER_UNIT = 0.9;
19
+ var MIN_FLIGHT_SECONDS = 0.2;
20
+ var MAX_FLIGHT_SECONDS = 4;
21
+ function flight(from, to, rho = DEFAULT_RHO) {
22
+ const u0 = normalizeView(from);
23
+ const u1 = normalizeView(to);
24
+ const safeRho = rho > 0 ? rho : DEFAULT_RHO;
25
+ const dx = u1.cx - u0.cx;
26
+ const dy = u1.cy - u0.cy;
27
+ const d2 = dx * dx + dy * dy;
28
+ const rho2 = safeRho * safeRho;
29
+ const rho4 = rho2 * rho2;
30
+ let length;
31
+ let at;
32
+ if (d2 < EPSILON_SQUARED) {
33
+ const ratio = Math.log(u1.w / u0.w) / safeRho;
34
+ length = Math.abs(ratio);
35
+ at = (t) => ({
36
+ cx: u0.cx + t * dx,
37
+ cy: u0.cy + t * dy,
38
+ w: u0.w * Math.exp(safeRho * t * ratio)
39
+ });
40
+ } else {
41
+ const d1 = Math.sqrt(d2);
42
+ const b0 = (u1.w * u1.w - u0.w * u0.w + rho4 * d2) / (2 * u0.w * rho2 * d1);
43
+ const b1 = (u1.w * u1.w - u0.w * u0.w - rho4 * d2) / (2 * u1.w * rho2 * d1);
44
+ const r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0);
45
+ const r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1);
46
+ const S = (r1 - r0) / safeRho;
47
+ const coshr0 = cosh(r0);
48
+ const sinhr0 = sinh(r0);
49
+ length = Math.abs(S);
50
+ at = (t) => {
51
+ const s = t * S;
52
+ const u = u0.w / (rho2 * d1) * (coshr0 * tanh(safeRho * s + r0) - sinhr0);
53
+ return {
54
+ cx: u0.cx + u * dx,
55
+ cy: u0.cy + u * dy,
56
+ w: u0.w * coshr0 / cosh(safeRho * s + r0)
57
+ };
58
+ };
59
+ }
60
+ const suggested = Math.min(
61
+ MAX_FLIGHT_SECONDS,
62
+ Math.max(MIN_FLIGHT_SECONDS, length * SECONDS_PER_UNIT)
63
+ );
64
+ return {
65
+ from: u0,
66
+ to: u1,
67
+ rho: safeRho,
68
+ length,
69
+ suggestedDuration: suggested,
70
+ at(t) {
71
+ if (!Number.isFinite(t) || t <= 0) return u0;
72
+ if (t >= 1) return u1;
73
+ const view = at(t);
74
+ return isFiniteTriple(view) ? view : u0;
75
+ }
76
+ };
77
+ }
78
+ function isFiniteTriple(view) {
79
+ return Number.isFinite(view.cx) && Number.isFinite(view.cy) && Number.isFinite(view.w);
80
+ }
81
+ function perceivedSpeedAt(f, t, h = 1e-5) {
82
+ const lo = Math.max(0, t - h);
83
+ const hi = Math.min(1, t + h);
84
+ const dt = hi - lo;
85
+ if (dt === 0) return 0;
86
+ const a = f.at(lo);
87
+ const b = f.at(hi);
88
+ const here = f.at(t);
89
+ if (here.w <= 0) return 0;
90
+ const pan = Math.hypot(b.cx - a.cx, b.cy - a.cy) / dt / here.w;
91
+ const zoom = Math.log(b.w / a.w) / dt;
92
+ return Math.hypot(f.rho * pan, zoom / f.rho);
93
+ }
94
+ function zoomRatio(a, b) {
95
+ const wa = Math.max(a.w, Number.EPSILON);
96
+ const wb = Math.max(b.w, Number.EPSILON);
97
+ return wa > wb ? wa / wb : wb / wa;
98
+ }
99
+
100
+ // src/camera/path.ts
101
+ function isView(target) {
102
+ return typeof target === "object" && target !== null && "w" in target;
103
+ }
104
+ function resolvePath(path, resolve) {
105
+ const resolved = [];
106
+ const unresolved = [];
107
+ path.waypoints.forEach((waypoint, index) => {
108
+ if (isView(waypoint.at)) {
109
+ resolved.push({ ...waypoint, at: waypoint.at, index });
110
+ return;
111
+ }
112
+ const view = resolve(waypoint.at);
113
+ if (view) {
114
+ resolved.push({ ...waypoint, at: view, ref: waypoint.at, index });
115
+ } else {
116
+ unresolved.push({ id: waypoint.id, index, ref: waypoint.at });
117
+ }
118
+ });
119
+ return { resolved, unresolved };
120
+ }
121
+ function arcFor(path, waypoint) {
122
+ return waypoint.arc ?? path.arc ?? DEFAULT_RHO;
123
+ }
124
+ function holdFor(path, waypoint) {
125
+ return Math.max(0, waypoint.hold ?? path.hold ?? 0);
126
+ }
127
+ function waypointIndex(path, id) {
128
+ return path.waypoints.findIndex((w) => w.id === id);
129
+ }
130
+ function duplicateIds(path) {
131
+ const seen = /* @__PURE__ */ new Set();
132
+ const dupes = /* @__PURE__ */ new Set();
133
+ for (const w of path.waypoints) {
134
+ if (seen.has(w.id)) dupes.add(w.id);
135
+ seen.add(w.id);
136
+ }
137
+ return [...dupes];
138
+ }
139
+
140
+ export { DEFAULT_RHO, MAX_FLIGHT_SECONDS, MIN_FLIGHT_SECONDS, arcFor, duplicateIds, flight, holdFor, isView, perceivedSpeedAt, resolvePath, waypointIndex, zoomRatio };
141
+ //# sourceMappingURL=chunk-RHTCDWZD.js.map
142
+ //# sourceMappingURL=chunk-RHTCDWZD.js.map