@plotwork.official/viewer 0.0.0-stage → 0.1.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -2
  3. package/dist/core/canvas-view.d.ts +154 -0
  4. package/dist/core/canvas-view.js +207 -0
  5. package/dist/core/fingerprint.d.ts +11 -0
  6. package/dist/core/fingerprint.js +20 -0
  7. package/dist/core/index.d.ts +13 -0
  8. package/dist/core/index.js +8 -0
  9. package/dist/core/limits.d.ts +19 -0
  10. package/dist/core/limits.js +19 -0
  11. package/dist/core/live-watch.d.ts +18 -0
  12. package/dist/core/live-watch.js +49 -0
  13. package/dist/core/picture-loader.d.ts +38 -0
  14. package/dist/core/picture-loader.js +58 -0
  15. package/dist/core/picture-map.d.ts +86 -0
  16. package/dist/core/picture-map.js +288 -0
  17. package/dist/core/picture-scheme.d.ts +18 -0
  18. package/dist/core/picture-scheme.js +31 -0
  19. package/dist/core/picture-source.d.ts +23 -0
  20. package/dist/core/picture-source.js +57 -0
  21. package/dist/element/copy.d.ts +22 -0
  22. package/dist/element/copy.js +28 -0
  23. package/dist/element/define.d.ts +7 -0
  24. package/dist/element/define.js +12 -0
  25. package/dist/element/dom.d.ts +12 -0
  26. package/dist/element/dom.js +25 -0
  27. package/dist/element/events.d.ts +39 -0
  28. package/dist/element/events.js +4 -0
  29. package/dist/element/gestures.d.ts +28 -0
  30. package/dist/element/gestures.js +142 -0
  31. package/dist/element/plotwork-diagram.d.ts +21 -0
  32. package/dist/element/plotwork-diagram.js +397 -0
  33. package/dist/element/shell.d.ts +22 -0
  34. package/dist/element/shell.js +30 -0
  35. package/dist/element/styles.d.ts +6 -0
  36. package/dist/element/styles.js +137 -0
  37. package/dist/element/trace-layer.d.ts +9 -0
  38. package/dist/element/trace-layer.js +45 -0
  39. package/dist/index.d.ts +7 -0
  40. package/dist/index.js +3 -0
  41. package/dist/react/index.d.ts +39 -0
  42. package/dist/react/index.js +52 -0
  43. package/dist/register.d.ts +1 -0
  44. package/dist/register.js +6 -0
  45. package/package.json +74 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Plotwork
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 CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # @plotwork.official/viewer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Embed interactive Plotwork diagrams in any page: pan and zoom, trace a box's lines, drill from an overview box into its part, light and dark, and live updates when a new version is published. A web component, `<plotwork-diagram>`, and a React component, `<PlotworkDiagram>`.
4
+
5
+ ## TL;DR
6
+
7
+ | Question | Answer |
8
+ |---|---|
9
+ | What does it show? | Any SVG the Plotwork engine drew: a file in your repository, a hosted picture, a stable link, or an organisation embed link. |
10
+ | What does it install? | Nothing else. No runtime dependencies; React is an optional peer for the React component. |
11
+ | Does it run the diagram engine? | No. A Plotwork picture already carries its boxes, lines, parts and source, so the viewer reads them. |
12
+ | Does it phone home? | No. It fetches only the address you give it, with no cookie. |
13
+ | Can a picture run script in my page? | No. It is shown as an `img`, and the element builds its own nodes without HTML text. |
14
+ | Live updates? | `live` checks the address every 60 seconds while the page is in view and swaps in a new version. |
15
+ | Pinning? | `fingerprint="sha256-..."` shows the picture only when it is exactly that one. |
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @plotwork.official/viewer
21
+ ```
22
+
23
+ ## Any page
24
+
25
+ ```html
26
+ <script type="module">
27
+ import '@plotwork.official/viewer/register'
28
+ </script>
29
+
30
+ <plotwork-diagram src="/diagrams/checkout.svg" label="Checkout flow"></plotwork-diagram>
31
+ ```
32
+
33
+ ## React
34
+
35
+ ```tsx
36
+ import { PlotworkDiagram } from '@plotwork.official/viewer/react'
37
+
38
+ export function Architecture() {
39
+ return <PlotworkDiagram src="https://cdn.plotwork.dev/l/<link>.svg" live onDrill={({ part }) => openPart(part)} />
40
+ }
41
+ ```
42
+
43
+ It renders as the bare element on a server and comes alive in the browser.
44
+
45
+ ## Where pictures come from
46
+
47
+ | Source | `src` | Notes |
48
+ |---|---|---|
49
+ | A file in your repository | `/diagrams/checkout.svg` | Drawn by the Plotwork command line, the MCP server or the GitHub Action. A picture saved with its source gives `source()` back. |
50
+ | A hosted picture | `https://cdn.plotwork.dev/d/<token>.svg` | Never changes. |
51
+ | A stable link | `https://cdn.plotwork.dev/l/<token>.svg` | Always the latest version of a named diagram. Add `live` to follow it. |
52
+ | An organisation's library | `https://cdn.plotwork.dev/o/<token>.svg` | An embed link the owner made for your site. Other sites get a placeholder. The source is never sent. |
53
+ | Text you already hold | the `svg` property | A bundler import, or a picture your server fetched. Wins over `src`. |
54
+
55
+ ## Attributes
56
+
57
+ | Attribute | Values | Default |
58
+ |---|---|---|
59
+ | `src` | An http or https address, or a path relative to the page | none |
60
+ | `theme` | `auto`, `light`, `dark` | `auto`: a picture drawn for both schemes follows the page |
61
+ | `live` | present or absent | absent |
62
+ | `interval` | seconds between checks for a live picture | `60`, never below `15` |
63
+ | `fingerprint` | `sha256-<64 hex>` | none. A pinned picture is never live. |
64
+ | `wheel` | `modifier` (Control or Command with the wheel zooms), `zoom` (a plain wheel zooms) | `modifier`; in full screen the wheel always zooms |
65
+ | `controls` | `none` hides the toolbar | shown |
66
+ | `label` | what the diagram shows, for a screen reader | none |
67
+
68
+ ## Methods, properties and events
69
+
70
+ | Name | What it does |
71
+ |---|---|
72
+ | `fit()`, `zoomIn()`, `zoomOut()` | Move the view. |
73
+ | `reload()` | Check the address again now. |
74
+ | `source()` | The source the picture carries, or `null`. |
75
+ | `fingerprint` | The fingerprint of the picture shown. |
76
+ | `svg` | Set the picture's text directly. |
77
+ | `labels` | Words in your language, over the English defaults. |
78
+ | `plotwork-load` | `{ fingerprint, hasSource }` |
79
+ | `plotwork-change` | `{ fingerprint, previous }`: a live picture changed. |
80
+ | `plotwork-error` | `{ reason }`: `address`, `missing`, `not-a-picture`, `network`, `fingerprint`, or `empty`. |
81
+ | `plotwork-drill` | `{ id, part, name }`: a box that opens a part was pressed. You decide what opens. |
82
+ | `plotwork-trace` | `{ id }`: the traced box, or `null`. |
83
+
84
+ ## Theming
85
+
86
+ Set custom properties on the element: `--plotwork-height`, `--plotwork-paper`, `--plotwork-ink`, `--plotwork-trace`, `--plotwork-radius`, `--plotwork-font`. Style the parts with `::part(stage)`, `::part(canvas)`, `::part(toolbar)`, `::part(button)` and `::part(notice)`.
87
+
88
+ ## Content security policy
89
+
90
+ | Directive | Needs |
91
+ |---|---|
92
+ | `img-src` | `blob:` (the picture is shown from a `blob:` address) |
93
+ | `connect-src` | the origin of each `src`, such as `https://cdn.plotwork.dev` |
94
+
95
+ ## See also
96
+
97
+ | Page | Why |
98
+ |---|---|
99
+ | [Picture contract](../../spec/picture.md) | What the viewer reads from a picture |
100
+ | [Core](./src/core/README.md) | The parts that need no element |
101
+ | [Element](./src/element/README.md) | How `<plotwork-diagram>` is built |
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The arithmetic of the viewer's canvas, with no browser in it: where the picture sits and how big it is drawn, for a
3
+ * canvas of a given size. A view is the picture's top left corner in the canvas (`x`, `y`, in pixels) and the factor it is
4
+ * drawn at (`scale`, 1 is the picture's own size). Every function takes a view and gives a new one that is already inside
5
+ * the rules, so the component that moves the picture never has to check them.
6
+ *
7
+ * The rules: the zoom is between 10 percent and 800 percent; the fit zoom is never above 100 percent, so a small diagram
8
+ * is not blown up to fill the window; the fitted picture is centred, except down a portrait canvas (a phone held upright), where
9
+ * it sits at the top so the space a short diagram leaves is below it, not above; a drag moves the picture freely at any zoom,
10
+ * until only a strip of it (`KEEP_PX`) is left in the canvas, so it can always be grabbed again.
11
+ */
12
+ export interface Size {
13
+ width: number;
14
+ height: number;
15
+ }
16
+ export interface Point {
17
+ x: number;
18
+ y: number;
19
+ }
20
+ export interface View {
21
+ scale: number;
22
+ x: number;
23
+ y: number;
24
+ }
25
+ /** How many pixels of the picture stay in the canvas however far it is dragged, so it can always be grabbed and brought back. */
26
+ export declare const KEEP_PX = 96;
27
+ /** True when a canvas and a picture both have a size to work with. Before the picture has loaded, or in a window with no layout, they do not. */
28
+ export declare function hasRoom(viewport: Size, picture: Size): boolean;
29
+ /** The zoom at which the whole picture is in the canvas with some air around it. Never above 1, so a small picture stays its own size. */
30
+ export declare function fitScale(viewport: Size, picture: Size): number;
31
+ /**
32
+ * The smallest and largest zoom. The smallest is 10 percent, or the fit zoom when a very large picture needs less than that
33
+ * to be seen whole, so Fit is always reachable.
34
+ */
35
+ export declare function zoomLimits(fit: number): {
36
+ min: number;
37
+ max: number;
38
+ };
39
+ /** A zoom held inside the limits. */
40
+ export declare function clampScale(scale: number, fit: number): number;
41
+ /** True for a canvas taller than it is wide: a phone held upright, where a centred picture would leave a gap above it. */
42
+ export declare function isPortrait(viewport: Size): boolean;
43
+ /** A view moved inside the rules for this canvas and picture. */
44
+ export declare function clampView(view: View, viewport: Size, picture: Size): View;
45
+ /** The picture at this zoom, centred in the canvas (at the top of a portrait one, when it fits down it). */
46
+ export declare function centred(scale: number, viewport: Size, picture: Size): View;
47
+ /** The picture fitted to the canvas and centred (at the top of a portrait one). */
48
+ export declare function fitView(viewport: Size, picture: Size): View;
49
+ /** The picture moved by `dx` and `dy` pixels. */
50
+ export declare function panBy(view: View, dx: number, dy: number, viewport: Size, picture: Size): View;
51
+ /**
52
+ * The picture at a new zoom, with the spot under `point` (in canvas pixels) staying under it: zooming with the pointer
53
+ * over a detail keeps the detail under the pointer. The zoom is held inside the limits first.
54
+ */
55
+ export declare function zoomAt(view: View, point: Point, scale: number, viewport: Size, picture: Size): View;
56
+ /**
57
+ * The view that shows a box of the picture (in the picture's own units): unchanged when the box is already whole in the canvas,
58
+ * else moved, at the same zoom, so the box is in the middle. Stepping from box to box with the keyboard never leaves one out of sight.
59
+ */
60
+ export declare function revealed(view: View, box: {
61
+ x: number;
62
+ y: number;
63
+ width: number;
64
+ height: number;
65
+ }, viewport: Size, picture: Size): View;
66
+ /** The largest zoom Zoom to selection goes to: close enough to read, never so close one box fills the window. */
67
+ export declare const BOX_ZOOM_MAX = 2;
68
+ /** The view that shows a box of the picture whole and centred, as large as fits up to `max` (`BOX_ZOOM_MAX`), for Zoom to selection. */
69
+ export declare function boxView(box: {
70
+ x: number;
71
+ y: number;
72
+ width: number;
73
+ height: number;
74
+ }, viewport: Size, picture: Size, max?: number): View;
75
+ /** The middle of the canvas, where a zoom button or key zooms around. */
76
+ export declare function middleOf(viewport: Size): Point;
77
+ /**
78
+ * The view while two fingers are down. The spot of the picture that was under the fingers' midpoint when they landed stays
79
+ * under their midpoint as it moves, and the zoom follows the distance between them, so a pinch zooms and a drag with two
80
+ * fingers pans, in one gesture.
81
+ */
82
+ export declare function pinchView(start: {
83
+ view: View;
84
+ mid: Point;
85
+ distance: number;
86
+ }, now: {
87
+ mid: Point;
88
+ distance: number;
89
+ }, viewport: Size, picture: Size): View;
90
+ /** True when the view is the fitted one, so a refit on resize is right and a double press means "zoom in". */
91
+ export declare function isFitted(view: View, viewport: Size, picture: Size): boolean;
92
+ /** True when the view is the fitted view itself, at the fit zoom and in its place, not one moved away from it by a drag. */
93
+ export declare function isFitView(view: View, viewport: Size, picture: Size): boolean;
94
+ /** What a double press does: from fitted, to 100 percent (or to 200 percent when the picture already fits at 100); from anything else, back to fit. */
95
+ export declare function toggledScale(view: View, viewport: Size, picture: Size): number;
96
+ /** The zoom as the whole percent a person reads. */
97
+ export declare function percentOf(scale: number): number;
98
+ /** How much a wheel turn zooms: smooth for a trackpad's pinch (small steps), and capped so a mouse wheel's notch is a step, not a leap. */
99
+ export declare function wheelZoomFactor(deltaY: number): number;
100
+ /**
101
+ * What a plain wheel does over a canvas. `zoom` is for a canvas that is the page (the editor, a viewer that fills the window or is in
102
+ * full screen): the wheel zooms. `modifier` is for a canvas inside a page that scrolls: the wheel scrolls the page, and only Control or
103
+ * Command with the wheel zooms.
104
+ */
105
+ export type WheelMode = 'zoom' | 'modifier';
106
+ /** What one wheel event is for: zooming at the pointer, moving the picture, or the page's own scroll. */
107
+ export type WheelIntent = 'zoom' | 'pan' | 'page';
108
+ /**
109
+ * Whether a wheel event came from a mouse's wheel rather than a two-finger swipe on a trackpad. Browsers do not say, so this reads
110
+ * the shape of the event: a mouse wheel reports lines or pages (`deltaMode` 1 or 2), or whole pixels in steps of a notch on one axis
111
+ * only. A swipe reports pixels, often fractional, often small, and often with a sideways part. A slow swipe that looks like a notch is
112
+ * read as a mouse and zooms, which is the safer of the two mistakes on a full-window canvas.
113
+ */
114
+ export declare function isMouseWheel(event: {
115
+ deltaX: number;
116
+ deltaY: number;
117
+ deltaMode: number;
118
+ }): boolean;
119
+ /**
120
+ * What a wheel event does. Control or Command with the wheel (and a trackpad's pinch, which arrives that way) always zooms. With
121
+ * neither, a `zoom` canvas zooms for a mouse wheel and moves the picture for a trackpad swipe, so a trackpad can still get about;
122
+ * a `modifier` canvas leaves the wheel to the page.
123
+ */
124
+ export declare function wheelIntent(event: {
125
+ deltaX: number;
126
+ deltaY: number;
127
+ deltaMode: number;
128
+ ctrlKey: boolean;
129
+ metaKey: boolean;
130
+ }, mode: WheelMode): WheelIntent;
131
+ /**
132
+ * What one turn of a mouse wheel multiplies the zoom by: proportional to how far it turned, so a notch (about 100 pixels) is close to
133
+ * a press of a zoom button and a fast spin is a few of them, never a leap.
134
+ */
135
+ export declare function mouseWheelZoomFactor(deltaY: number): number;
136
+ /** The dot grid's step at 100 percent, in pixels: the portal's own `--canvas-step` (1.5rem). */
137
+ export declare const DOT_STEP = 24;
138
+ /**
139
+ * Where the dot grid behind the picture is drawn for a view: its step on screen and its offset, so the dots move and scale with
140
+ * the picture like the paper it is drawn on. Zoomed far out the grid takes every second dot, and zoomed far in it adds one between,
141
+ * so it never turns into a grey wash or a sparse scatter.
142
+ */
143
+ export declare function dotGrid(view: View): {
144
+ size: number;
145
+ x: number;
146
+ y: number;
147
+ };
148
+ /** The dot grid as the inline style of the element that pans: the CSS draws the dots, this places them. */
149
+ export declare function dotGridStyle(view: View): {
150
+ backgroundSize: string;
151
+ backgroundPosition: string;
152
+ };
153
+ /** A wheel's distance in pixels, whether the device reports lines, pages, or pixels. */
154
+ export declare function wheelPixels(delta: number, mode: number, pageSize: number): number;
@@ -0,0 +1,207 @@
1
+ import { VIEWER_MAX_ZOOM, VIEWER_MIN_ZOOM, VIEWER_PADDING } from './limits.js';
2
+ function clamp(value, min, max) {
3
+ return Math.min(Math.max(value, min), max);
4
+ }
5
+ /** How many pixels of the picture stay in the canvas however far it is dragged, so it can always be grabbed and brought back. */
6
+ export const KEEP_PX = 96;
7
+ /** True when a canvas and a picture both have a size to work with. Before the picture has loaded, or in a window with no layout, they do not. */
8
+ export function hasRoom(viewport, picture) {
9
+ return viewport.width > 0 && viewport.height > 0 && picture.width > 0 && picture.height > 0;
10
+ }
11
+ /** The zoom at which the whole picture is in the canvas with some air around it. Never above 1, so a small picture stays its own size. */
12
+ export function fitScale(viewport, picture) {
13
+ if (!hasRoom(viewport, picture))
14
+ return 1;
15
+ const across = Math.max(1, viewport.width - VIEWER_PADDING * 2) / picture.width;
16
+ const down = Math.max(1, viewport.height - VIEWER_PADDING * 2) / picture.height;
17
+ return Math.min(1, across, down);
18
+ }
19
+ /**
20
+ * The smallest and largest zoom. The smallest is 10 percent, or the fit zoom when a very large picture needs less than that
21
+ * to be seen whole, so Fit is always reachable.
22
+ */
23
+ export function zoomLimits(fit) {
24
+ return { min: Math.min(VIEWER_MIN_ZOOM, fit), max: VIEWER_MAX_ZOOM };
25
+ }
26
+ /** A zoom held inside the limits. */
27
+ export function clampScale(scale, fit) {
28
+ const { min, max } = zoomLimits(fit);
29
+ return clamp(scale, min, max);
30
+ }
31
+ /**
32
+ * Where one axis of the picture may sit: centred when it fits (or at the start, past the air, when `top` is set), else free
33
+ * between the two edges plus the air.
34
+ */
35
+ function settle(offset, extent, size) {
36
+ const keep = Math.min(KEEP_PX, size, extent);
37
+ return clamp(offset, keep - size, extent - keep);
38
+ }
39
+ /** True for a canvas taller than it is wide: a phone held upright, where a centred picture would leave a gap above it. */
40
+ export function isPortrait(viewport) {
41
+ return viewport.height > viewport.width;
42
+ }
43
+ /** A view moved inside the rules for this canvas and picture. */
44
+ export function clampView(view, viewport, picture) {
45
+ if (!hasRoom(viewport, picture))
46
+ return view;
47
+ return {
48
+ scale: view.scale,
49
+ x: settle(view.x, viewport.width, picture.width * view.scale),
50
+ y: settle(view.y, viewport.height, picture.height * view.scale),
51
+ };
52
+ }
53
+ /** The picture at this zoom, centred in the canvas (at the top of a portrait one, when it fits down it). */
54
+ export function centred(scale, viewport, picture) {
55
+ const height = picture.height * scale;
56
+ const top = isPortrait(viewport) && height + VIEWER_PADDING * 2 <= viewport.height;
57
+ return clampView({ scale, x: (viewport.width - picture.width * scale) / 2, y: top ? VIEWER_PADDING : (viewport.height - height) / 2 }, viewport, picture);
58
+ }
59
+ /** The picture fitted to the canvas and centred (at the top of a portrait one). */
60
+ export function fitView(viewport, picture) {
61
+ return centred(fitScale(viewport, picture), viewport, picture);
62
+ }
63
+ /** The picture moved by `dx` and `dy` pixels. */
64
+ export function panBy(view, dx, dy, viewport, picture) {
65
+ return clampView({ ...view, x: view.x + dx, y: view.y + dy }, viewport, picture);
66
+ }
67
+ /**
68
+ * The picture at a new zoom, with the spot under `point` (in canvas pixels) staying under it: zooming with the pointer
69
+ * over a detail keeps the detail under the pointer. The zoom is held inside the limits first.
70
+ */
71
+ export function zoomAt(view, point, scale, viewport, picture) {
72
+ const next = clampScale(scale, fitScale(viewport, picture));
73
+ const ratio = next / view.scale;
74
+ return clampView({ scale: next, x: point.x - (point.x - view.x) * ratio, y: point.y - (point.y - view.y) * ratio }, viewport, picture);
75
+ }
76
+ /**
77
+ * The view that shows a box of the picture (in the picture's own units): unchanged when the box is already whole in the canvas,
78
+ * else moved, at the same zoom, so the box is in the middle. Stepping from box to box with the keyboard never leaves one out of sight.
79
+ */
80
+ export function revealed(view, box, viewport, picture) {
81
+ const left = view.x + box.x * view.scale;
82
+ const top = view.y + box.y * view.scale;
83
+ const right = left + box.width * view.scale;
84
+ const bottom = top + box.height * view.scale;
85
+ if (left >= 0 && top >= 0 && right <= viewport.width && bottom <= viewport.height)
86
+ return view;
87
+ const middle = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
88
+ return clampView({ ...view, x: viewport.width / 2 - middle.x * view.scale, y: viewport.height / 2 - middle.y * view.scale }, viewport, picture);
89
+ }
90
+ /** The largest zoom Zoom to selection goes to: close enough to read, never so close one box fills the window. */
91
+ export const BOX_ZOOM_MAX = 2;
92
+ /** The view that shows a box of the picture whole and centred, as large as fits up to `max` (`BOX_ZOOM_MAX`), for Zoom to selection. */
93
+ export function boxView(box, viewport, picture, max = BOX_ZOOM_MAX) {
94
+ if (!hasRoom(viewport, picture) || box.width <= 0 || box.height <= 0)
95
+ return fitView(viewport, picture);
96
+ const across = Math.max(1, viewport.width - VIEWER_PADDING * 4) / box.width;
97
+ const down = Math.max(1, viewport.height - VIEWER_PADDING * 4) / box.height;
98
+ const scale = clampScale(Math.min(across, down, max), fitScale(viewport, picture));
99
+ const middle = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
100
+ return clampView({ scale, x: viewport.width / 2 - middle.x * scale, y: viewport.height / 2 - middle.y * scale }, viewport, picture);
101
+ }
102
+ /** The middle of the canvas, where a zoom button or key zooms around. */
103
+ export function middleOf(viewport) {
104
+ return { x: viewport.width / 2, y: viewport.height / 2 };
105
+ }
106
+ /**
107
+ * The view while two fingers are down. The spot of the picture that was under the fingers' midpoint when they landed stays
108
+ * under their midpoint as it moves, and the zoom follows the distance between them, so a pinch zooms and a drag with two
109
+ * fingers pans, in one gesture.
110
+ */
111
+ export function pinchView(start, now, viewport, picture) {
112
+ const fit = fitScale(viewport, picture);
113
+ const ratio = start.distance > 0 ? now.distance / start.distance : 1;
114
+ const scale = clampScale(start.view.scale * ratio, fit);
115
+ const spot = { x: (start.mid.x - start.view.x) / start.view.scale, y: (start.mid.y - start.view.y) / start.view.scale };
116
+ return clampView({ scale, x: now.mid.x - spot.x * scale, y: now.mid.y - spot.y * scale }, viewport, picture);
117
+ }
118
+ /** How near the fit zoom a zoom has to be to count as fitted: a hair, so a rounding difference is not "zoomed". */
119
+ const FIT_TOLERANCE = 0.002;
120
+ /** True when the view is the fitted one, so a refit on resize is right and a double press means "zoom in". */
121
+ export function isFitted(view, viewport, picture) {
122
+ return Math.abs(view.scale - fitScale(viewport, picture)) < FIT_TOLERANCE;
123
+ }
124
+ /** True when the view is the fitted view itself, at the fit zoom and in its place, not one moved away from it by a drag. */
125
+ export function isFitView(view, viewport, picture) {
126
+ const fit = fitView(viewport, picture);
127
+ return isFitted(view, viewport, picture) && Math.abs(view.x - fit.x) < 0.5 && Math.abs(view.y - fit.y) < 0.5;
128
+ }
129
+ /** What a double press does: from fitted, to 100 percent (or to 200 percent when the picture already fits at 100); from anything else, back to fit. */
130
+ export function toggledScale(view, viewport, picture) {
131
+ const fit = fitScale(viewport, picture);
132
+ if (!isFitted(view, viewport, picture))
133
+ return fit;
134
+ return fit < 1 ? 1 : 2;
135
+ }
136
+ /** The zoom as the whole percent a person reads. */
137
+ export function percentOf(scale) {
138
+ return Math.round(scale * 100);
139
+ }
140
+ /** How much a wheel turn zooms: smooth for a trackpad's pinch (small steps), and capped so a mouse wheel's notch is a step, not a leap. */
141
+ export function wheelZoomFactor(deltaY) {
142
+ return Math.exp(-clamp(deltaY, -60, 60) * 0.008);
143
+ }
144
+ /** The least a mouse notch moves in pixels. A trackpad sends many smaller events instead. */
145
+ const MOUSE_NOTCH_PX = 40;
146
+ /**
147
+ * Whether a wheel event came from a mouse's wheel rather than a two-finger swipe on a trackpad. Browsers do not say, so this reads
148
+ * the shape of the event: a mouse wheel reports lines or pages (`deltaMode` 1 or 2), or whole pixels in steps of a notch on one axis
149
+ * only. A swipe reports pixels, often fractional, often small, and often with a sideways part. A slow swipe that looks like a notch is
150
+ * read as a mouse and zooms, which is the safer of the two mistakes on a full-window canvas.
151
+ */
152
+ export function isMouseWheel(event) {
153
+ if (event.deltaMode !== 0)
154
+ return true;
155
+ return event.deltaX === 0 && Number.isInteger(event.deltaY) && Math.abs(event.deltaY) >= MOUSE_NOTCH_PX;
156
+ }
157
+ /**
158
+ * What a wheel event does. Control or Command with the wheel (and a trackpad's pinch, which arrives that way) always zooms. With
159
+ * neither, a `zoom` canvas zooms for a mouse wheel and moves the picture for a trackpad swipe, so a trackpad can still get about;
160
+ * a `modifier` canvas leaves the wheel to the page.
161
+ */
162
+ export function wheelIntent(event, mode) {
163
+ if (event.ctrlKey || event.metaKey)
164
+ return 'zoom';
165
+ if (mode === 'modifier')
166
+ return 'page';
167
+ return isMouseWheel(event) ? 'zoom' : 'pan';
168
+ }
169
+ /**
170
+ * What one turn of a mouse wheel multiplies the zoom by: proportional to how far it turned, so a notch (about 100 pixels) is close to
171
+ * a press of a zoom button and a fast spin is a few of them, never a leap.
172
+ */
173
+ export function mouseWheelZoomFactor(deltaY) {
174
+ return Math.exp(-clamp(deltaY, -240, 240) * 0.0022);
175
+ }
176
+ /** The dot grid's step at 100 percent, in pixels: the portal's own `--canvas-step` (1.5rem). */
177
+ export const DOT_STEP = 24;
178
+ /** The narrowest and widest a step of the dot grid is drawn on screen. Past either, the grid halves or doubles. */
179
+ const DOT_MIN_PX = 14;
180
+ const DOT_MAX_PX = 40;
181
+ /**
182
+ * Where the dot grid behind the picture is drawn for a view: its step on screen and its offset, so the dots move and scale with
183
+ * the picture like the paper it is drawn on. Zoomed far out the grid takes every second dot, and zoomed far in it adds one between,
184
+ * so it never turns into a grey wash or a sparse scatter.
185
+ */
186
+ export function dotGrid(view) {
187
+ let size = DOT_STEP * (Number.isFinite(view.scale) && view.scale > 0 ? view.scale : 1);
188
+ while (size < DOT_MIN_PX)
189
+ size *= 2;
190
+ while (size > DOT_MAX_PX)
191
+ size /= 2;
192
+ const wrap = (offset) => ((offset % size) + size) % size;
193
+ return { size, x: wrap(view.x), y: wrap(view.y) };
194
+ }
195
+ /** The dot grid as the inline style of the element that pans: the CSS draws the dots, this places them. */
196
+ export function dotGridStyle(view) {
197
+ const grid = dotGrid(view);
198
+ return { backgroundSize: `${grid.size}px ${grid.size}px`, backgroundPosition: `${grid.x}px ${grid.y}px` };
199
+ }
200
+ /** A wheel's distance in pixels, whether the device reports lines, pages, or pixels. */
201
+ export function wheelPixels(delta, mode, pageSize) {
202
+ if (mode === 1)
203
+ return delta * 16;
204
+ if (mode === 2)
205
+ return delta * pageSize;
206
+ return delta;
207
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A picture's fingerprint: `sha256-` and the SHA-256 of its text in UTF-8, as lower-case hex. The same value an organisation embed
3
+ * link sends as its `ETag` (without the quotes), and what `fingerprint` pins a viewer to. Computed with Web Crypto, so it works in
4
+ * every browser and in Node 20 and later with nothing installed.
5
+ */
6
+ /** The prefix every fingerprint starts with, naming the hash. */
7
+ export declare const FINGERPRINT_PREFIX = "sha256-";
8
+ /** True for a value with the shape of a fingerprint. */
9
+ export declare function isFingerprint(value: string): boolean;
10
+ /** The fingerprint of a picture's text. */
11
+ export declare function fingerprintOf(text: string): Promise<string>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A picture's fingerprint: `sha256-` and the SHA-256 of its text in UTF-8, as lower-case hex. The same value an organisation embed
3
+ * link sends as its `ETag` (without the quotes), and what `fingerprint` pins a viewer to. Computed with Web Crypto, so it works in
4
+ * every browser and in Node 20 and later with nothing installed.
5
+ */
6
+ /** The prefix every fingerprint starts with, naming the hash. */
7
+ export const FINGERPRINT_PREFIX = 'sha256-';
8
+ const FINGERPRINT = /^sha256-[0-9a-f]{64}$/;
9
+ /** True for a value with the shape of a fingerprint. */
10
+ export function isFingerprint(value) {
11
+ return FINGERPRINT.test(value);
12
+ }
13
+ /** The fingerprint of a picture's text. */
14
+ export async function fingerprintOf(text) {
15
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
16
+ let hex = '';
17
+ for (const byte of new Uint8Array(digest))
18
+ hex += byte.toString(16).padStart(2, '0');
19
+ return FINGERPRINT_PREFIX + hex;
20
+ }
@@ -0,0 +1,13 @@
1
+ export * from './canvas-view.js';
2
+ export { drillNodeAt, nodeAt, parsePicture, readTraceMap, traceableNodes, traceMapOf, traceOf, PATH_DATA } from './picture-map.js';
3
+ export type { Trace, TraceBox, TraceLine, TraceMap, TraceNode, TracePath, TracedLine } from './picture-map.js';
4
+ export { readEmbeddedSource } from './picture-source.js';
5
+ export type { SourceReading } from './picture-source.js';
6
+ export { drawnScheme, themeChoiceOf, withScheme } from './picture-scheme.js';
7
+ export type { Scheme, ThemeChoice } from './picture-scheme.js';
8
+ export { FINGERPRINT_PREFIX, fingerprintOf, isFingerprint } from './fingerprint.js';
9
+ export { loadPicture, pictureUrl, PictureLoadError } from './picture-loader.js';
10
+ export type { LoadFailure, LoadOptions } from './picture-loader.js';
11
+ export { documentHost, liveIntervalOf, watchLive } from './live-watch.js';
12
+ export type { WatchHost } from './live-watch.js';
13
+ export * from './limits.js';
@@ -0,0 +1,8 @@
1
+ export * from './canvas-view.js';
2
+ export { drillNodeAt, nodeAt, parsePicture, readTraceMap, traceableNodes, traceMapOf, traceOf, PATH_DATA } from './picture-map.js';
3
+ export { readEmbeddedSource } from './picture-source.js';
4
+ export { drawnScheme, themeChoiceOf, withScheme } from './picture-scheme.js';
5
+ export { FINGERPRINT_PREFIX, fingerprintOf, isFingerprint } from './fingerprint.js';
6
+ export { loadPicture, pictureUrl, PictureLoadError } from './picture-loader.js';
7
+ export { documentHost, liveIntervalOf, watchLive } from './live-watch.js';
8
+ export * from './limits.js';
@@ -0,0 +1,19 @@
1
+ /** The smallest zoom, 10 percent, unless a very large picture needs less to be seen whole. */
2
+ export declare const VIEWER_MIN_ZOOM = 0.1;
3
+ /** The largest zoom, 800 percent. */
4
+ export declare const VIEWER_MAX_ZOOM = 8;
5
+ /** What a zoom button or key multiplies the zoom by. */
6
+ export declare const VIEWER_ZOOM_STEP = 1.25;
7
+ /** How far an arrow key moves the picture, in pixels. */
8
+ export declare const VIEWER_PAN_STEP = 64;
9
+ /** The air around a fitted picture, in pixels. */
10
+ export declare const VIEWER_PADDING = 24;
11
+ /** The most text of a picture the viewer reads. The largest hosted picture is 2 MiB; a response past this is not one. */
12
+ export declare const PICTURE_MAX_BYTES: number;
13
+ /** How long a fetch of a picture may take before it counts as failed, in milliseconds. */
14
+ export declare const REQUEST_TIMEOUT_MS = 15000;
15
+ /** The default and the shortest time between two checks for a new version of a live picture, in seconds. */
16
+ export declare const LIVE_INTERVAL_DEFAULT_S = 60;
17
+ export declare const LIVE_INTERVAL_MIN_S = 15;
18
+ /** How far a pointer may move between down and up and still count as a press, not a drag, in pixels. */
19
+ export declare const PRESS_SLOP_PX = 4;
@@ -0,0 +1,19 @@
1
+ /** The smallest zoom, 10 percent, unless a very large picture needs less to be seen whole. */
2
+ export const VIEWER_MIN_ZOOM = 0.1;
3
+ /** The largest zoom, 800 percent. */
4
+ export const VIEWER_MAX_ZOOM = 8;
5
+ /** What a zoom button or key multiplies the zoom by. */
6
+ export const VIEWER_ZOOM_STEP = 1.25;
7
+ /** How far an arrow key moves the picture, in pixels. */
8
+ export const VIEWER_PAN_STEP = 64;
9
+ /** The air around a fitted picture, in pixels. */
10
+ export const VIEWER_PADDING = 24;
11
+ /** The most text of a picture the viewer reads. The largest hosted picture is 2 MiB; a response past this is not one. */
12
+ export const PICTURE_MAX_BYTES = 3 * 1024 * 1024;
13
+ /** How long a fetch of a picture may take before it counts as failed, in milliseconds. */
14
+ export const REQUEST_TIMEOUT_MS = 15_000;
15
+ /** The default and the shortest time between two checks for a new version of a live picture, in seconds. */
16
+ export const LIVE_INTERVAL_DEFAULT_S = 60;
17
+ export const LIVE_INTERVAL_MIN_S = 15;
18
+ /** How far a pointer may move between down and up and still count as a press, not a drag, in pixels. */
19
+ export const PRESS_SLOP_PX = 4;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Watching a live picture for a new version. Every `interval` seconds, and once when the page comes back into view, it calls
3
+ * `check`; it never checks while the page is hidden, so a tab in the background costs no request. A check that fails is skipped,
4
+ * and the next one tries again: a picture already shown stays shown.
5
+ */
6
+ /** The interval from an `interval` attribute, in seconds: the default when it is not a number, and never below the minimum. */
7
+ export declare function liveIntervalOf(value: string | null | undefined): number;
8
+ /** What the watch reads from the page, so a test can stand in for it. */
9
+ export interface WatchHost {
10
+ isVisible(): boolean;
11
+ onVisible(listener: () => void): () => void;
12
+ setInterval(handler: () => void, ms: number): unknown;
13
+ clearInterval(handle: unknown): void;
14
+ }
15
+ /** The browser's page, as a watch host. */
16
+ export declare function documentHost(document: Document): WatchHost;
17
+ /** Starts watching; the function it returns stops it. Two checks never overlap. */
18
+ export declare function watchLive(host: WatchHost, intervalS: number, check: () => Promise<void>): () => void;
@@ -0,0 +1,49 @@
1
+ import { LIVE_INTERVAL_DEFAULT_S, LIVE_INTERVAL_MIN_S } from './limits.js';
2
+ /**
3
+ * Watching a live picture for a new version. Every `interval` seconds, and once when the page comes back into view, it calls
4
+ * `check`; it never checks while the page is hidden, so a tab in the background costs no request. A check that fails is skipped,
5
+ * and the next one tries again: a picture already shown stays shown.
6
+ */
7
+ /** The interval from an `interval` attribute, in seconds: the default when it is not a number, and never below the minimum. */
8
+ export function liveIntervalOf(value) {
9
+ const seconds = value === null || value === undefined || value.trim() === '' ? Number.NaN : Number(value);
10
+ if (!Number.isFinite(seconds))
11
+ return LIVE_INTERVAL_DEFAULT_S;
12
+ return Math.max(LIVE_INTERVAL_MIN_S, Math.round(seconds));
13
+ }
14
+ /** The browser's page, as a watch host. */
15
+ export function documentHost(document) {
16
+ return {
17
+ isVisible: () => document.visibilityState === 'visible',
18
+ onVisible(listener) {
19
+ const handler = () => {
20
+ if (document.visibilityState === 'visible')
21
+ listener();
22
+ };
23
+ document.addEventListener('visibilitychange', handler);
24
+ return () => document.removeEventListener('visibilitychange', handler);
25
+ },
26
+ setInterval: (handler, ms) => globalThis.setInterval(handler, ms),
27
+ clearInterval: (handle) => globalThis.clearInterval(handle),
28
+ };
29
+ }
30
+ /** Starts watching; the function it returns stops it. Two checks never overlap. */
31
+ export function watchLive(host, intervalS, check) {
32
+ let running = false;
33
+ const tick = () => {
34
+ if (running || !host.isVisible())
35
+ return;
36
+ running = true;
37
+ check()
38
+ .catch(() => undefined)
39
+ .finally(() => {
40
+ running = false;
41
+ });
42
+ };
43
+ const handle = host.setInterval(tick, intervalS * 1000);
44
+ const stopVisible = host.onVisible(tick);
45
+ return () => {
46
+ host.clearInterval(handle);
47
+ stopVisible();
48
+ };
49
+ }