@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
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Fetching a picture's text from its address: a file beside the page, a hosted picture (`cdn.plotwork.dev/d/...`), a stable link
3
+ * (`/l/...`), or an organisation's embed link (`/o/...`). It sends no cookie, asks for an image, and refuses a response past
4
+ * `PICTURE_MAX_BYTES` before reading it whole. A failure is a `PictureLoadError` with a reason the element shows, never a
5
+ * stack trace.
6
+ *
7
+ * `revalidate` asks the browser to check its copy with the server before using it (`cache: 'no-cache'`): the browser sends the tag it
8
+ * holds, the server answers 304 when nothing changed, and the browser hands back its copy. That is how a live viewer sees a new
9
+ * version without downloading the picture again every time.
10
+ */
11
+ /** Why a picture could not be loaded. */
12
+ export type LoadFailure =
13
+ /** The address is not one the viewer fetches: not http, https, or a path relative to the page. */
14
+ 'address'
15
+ /** The server answered 404 or 403. */
16
+ | 'missing'
17
+ /** The answer is not an SVG, or is larger than any picture. */
18
+ | 'not-a-picture'
19
+ /** No answer: offline, refused by the page's content security policy or CORS, or timed out. */
20
+ | 'network'
21
+ /** The picture's fingerprint is not the one the page pinned. */
22
+ | 'fingerprint';
23
+ export declare class PictureLoadError extends Error {
24
+ readonly reason: LoadFailure;
25
+ constructor(reason: LoadFailure);
26
+ }
27
+ /** The address resolved against the page, or null when it is not http or https. A `javascript:` or `data:` address is refused. */
28
+ export declare function pictureUrl(src: string, base: string): URL | null;
29
+ /** What a fetch needs besides the address. */
30
+ export interface LoadOptions {
31
+ signal?: AbortSignal | undefined;
32
+ /** Check the browser's copy with the server first, for a live viewer. */
33
+ revalidate?: boolean | undefined;
34
+ /** The `fetch` to call; the global one when left out, so a test or a host can supply its own. */
35
+ fetcher?: typeof fetch | undefined;
36
+ }
37
+ /** The text of the picture at an address. */
38
+ export declare function loadPicture(url: URL, options?: LoadOptions): Promise<string>;
@@ -0,0 +1,58 @@
1
+ import { PICTURE_MAX_BYTES, REQUEST_TIMEOUT_MS } from './limits.js';
2
+ export class PictureLoadError extends Error {
3
+ reason;
4
+ constructor(reason) {
5
+ super(`The picture could not be loaded (${reason})`);
6
+ this.name = 'PictureLoadError';
7
+ this.reason = reason;
8
+ }
9
+ }
10
+ /** The address resolved against the page, or null when it is not http or https. A `javascript:` or `data:` address is refused. */
11
+ export function pictureUrl(src, base) {
12
+ let url;
13
+ try {
14
+ url = new URL(src, base);
15
+ }
16
+ catch {
17
+ // Not an address at all: the caller says so.
18
+ return null;
19
+ }
20
+ return url.protocol === 'https:' || url.protocol === 'http:' ? url : null;
21
+ }
22
+ /** The text of the picture at an address. */
23
+ export async function loadPicture(url, options = {}) {
24
+ const timeout = new AbortController();
25
+ const timer = setTimeout(() => timeout.abort(), REQUEST_TIMEOUT_MS);
26
+ const abort = () => timeout.abort();
27
+ options.signal?.addEventListener('abort', abort, { once: true });
28
+ const fetcher = options.fetcher ?? fetch;
29
+ try {
30
+ const response = await fetcher(url.href, {
31
+ credentials: 'omit',
32
+ headers: { Accept: 'image/svg+xml' },
33
+ cache: options.revalidate === true ? 'no-cache' : 'default',
34
+ signal: timeout.signal,
35
+ });
36
+ if (response.status === 404 || response.status === 403)
37
+ throw new PictureLoadError('missing');
38
+ if (!response.ok)
39
+ throw new PictureLoadError('network');
40
+ const declared = Number(response.headers.get('content-length'));
41
+ if (Number.isFinite(declared) && declared > PICTURE_MAX_BYTES)
42
+ throw new PictureLoadError('not-a-picture');
43
+ const text = await response.text();
44
+ if (text.length > PICTURE_MAX_BYTES || !/<svg[\s>]/.test(text))
45
+ throw new PictureLoadError('not-a-picture');
46
+ return text;
47
+ }
48
+ catch (error) {
49
+ if (error instanceof PictureLoadError || options.signal?.aborted === true)
50
+ throw error;
51
+ // No answer (offline, refused, or timed out) is one reason to the page, like every other failure here.
52
+ throw new PictureLoadError('network');
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ options.signal?.removeEventListener('abort', abort);
57
+ }
58
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The boxes and lines of a painted picture, read from its text so the viewer can trace a box's lines over the `img`. Kept equal to
3
+ * the Plotwork portal's reader (`apps/app/src/lib/picture-trace.ts`); `spec/picture.md` is the contract both read.
4
+ *
5
+ * The engine marks what it paints: a box is a group with `data-node` (or `data-state-id` for a state's start, end, choice and
6
+ * history marks), and a line is a group with `data-edge`, `data-link`, `data-transition` or `data-flow` and the ids of its two
7
+ * ends in `data-from` and `data-to`. A Gantt link names its ends in its own id (`data-link="a>b"`), a mind map ribbon is a path
8
+ * with `data-link-from` and `data-link-to` holding topic numbers (the topic is `t<number>`), and a Sankey flow names its ends by
9
+ * the words on their boxes. A line's label is a group with `data-edge-label` equal to the line's id. A kind that marks no ends
10
+ * (a sequence's messages, a class or table relation, a git graph) has nothing to trace, and the map has no lines.
11
+ *
12
+ * The text is parsed with `DOMParser` into a document that is never attached to the page, so nothing in it is drawn or run.
13
+ * Only numbers, ids, the words on a box, and path data that passes `PATH_DATA` leave this module. Path data is drawn again by
14
+ * the viewer, so anything in it but commands and numbers is dropped. A mistake is a result (`null`, or a box left out), never an
15
+ * exception.
16
+ */
17
+ export interface TraceBox {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+ /** One path of a line, where it is drawn. `filled` is a shape painted by its fill (a ribbon), not a stroke. */
24
+ export interface TracePath {
25
+ d: string;
26
+ dx: number;
27
+ dy: number;
28
+ filled: boolean;
29
+ }
30
+ export interface TraceNode {
31
+ id: string;
32
+ /** The words on the box, for a screen reader. A state's mark with no words is named by its kind (Start, End), anything else by its id. */
33
+ name: string;
34
+ box: TraceBox;
35
+ /** The corner radius of the box's own shape, so its outline follows it. */
36
+ radius: number;
37
+ /** The place in its series of the diagram that draws this box in full, from the engine's `data-part` (`part=`); absent for none. */
38
+ part?: number;
39
+ }
40
+ export interface TraceLine {
41
+ id: string;
42
+ from: string;
43
+ to: string;
44
+ paths: TracePath[];
45
+ label: TraceBox | null;
46
+ }
47
+ export interface TraceMap {
48
+ viewBox: TraceBox;
49
+ nodes: TraceNode[];
50
+ lines: TraceLine[];
51
+ }
52
+ /** One line of a traced box, from that box's side: the line, which way it runs, and the box at its other end. */
53
+ export interface TracedLine {
54
+ line: TraceLine;
55
+ direction: 'to' | 'from';
56
+ other: TraceNode;
57
+ }
58
+ export interface Trace {
59
+ node: TraceNode;
60
+ lines: TracedLine[];
61
+ }
62
+ /** Path data the viewer will draw again: path commands, numbers, spaces, commas, dots and minus signs, and nothing else. */
63
+ export declare const PATH_DATA: RegExp;
64
+ /**
65
+ * The boxes and lines of a painted picture. Null when the text is not an SVG or has no size. A picture whose kind marks no ends
66
+ * gives a map with no lines, which the viewer takes to mean there is nothing to trace.
67
+ */
68
+ export declare function readTraceMap(svg: string): TraceMap | null;
69
+ /** The root of a picture's text, parsed into a document that is never attached to the page; null when it is not an SVG. */
70
+ export declare function parsePicture(svg: string): Element | null;
71
+ /** The trace map of a picture already parsed with `parsePicture`, so a reader that needs more of it parses it once. */
72
+ export declare function traceMapOf(root: Element): TraceMap | null;
73
+ /** The boxes that have at least one line, in reading order: top to bottom, then left to right. These are the ones a person can trace. */
74
+ export declare function traceableNodes(map: TraceMap): TraceNode[];
75
+ /** A box's lines, each from that box's side. Null for a box with no lines or no such box. */
76
+ export declare function traceOf(map: TraceMap, id: string): Trace | null;
77
+ /** The traceable box under a point of the picture: the smallest one that holds it, so a box inside a box is reachable. */
78
+ export declare function nodeAt(map: TraceMap, point: {
79
+ x: number;
80
+ y: number;
81
+ }): TraceNode | null;
82
+ /** The box under a point of the picture that opens a part of its series (`part=`): the smallest one that holds it. */
83
+ export declare function drillNodeAt(map: TraceMap, point: {
84
+ x: number;
85
+ y: number;
86
+ }): TraceNode | null;
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The boxes and lines of a painted picture, read from its text so the viewer can trace a box's lines over the `img`. Kept equal to
3
+ * the Plotwork portal's reader (`apps/app/src/lib/picture-trace.ts`); `spec/picture.md` is the contract both read.
4
+ *
5
+ * The engine marks what it paints: a box is a group with `data-node` (or `data-state-id` for a state's start, end, choice and
6
+ * history marks), and a line is a group with `data-edge`, `data-link`, `data-transition` or `data-flow` and the ids of its two
7
+ * ends in `data-from` and `data-to`. A Gantt link names its ends in its own id (`data-link="a>b"`), a mind map ribbon is a path
8
+ * with `data-link-from` and `data-link-to` holding topic numbers (the topic is `t<number>`), and a Sankey flow names its ends by
9
+ * the words on their boxes. A line's label is a group with `data-edge-label` equal to the line's id. A kind that marks no ends
10
+ * (a sequence's messages, a class or table relation, a git graph) has nothing to trace, and the map has no lines.
11
+ *
12
+ * The text is parsed with `DOMParser` into a document that is never attached to the page, so nothing in it is drawn or run.
13
+ * Only numbers, ids, the words on a box, and path data that passes `PATH_DATA` leave this module. Path data is drawn again by
14
+ * the viewer, so anything in it but commands and numbers is dropped. A mistake is a result (`null`, or a box left out), never an
15
+ * exception.
16
+ */
17
+ /** Path data the viewer will draw again: path commands, numbers, spaces, commas, dots and minus signs, and nothing else. */
18
+ export const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9 ,.-]+$/;
19
+ const BOX_MARKS = ['data-node', 'data-state-id'];
20
+ const LINE_MARKS = ['data-edge', 'data-link', 'data-transition', 'data-flow'];
21
+ const SHAPES = 'rect, circle, ellipse, polygon, path';
22
+ const num = (value) => {
23
+ const parsed = value === null ? Number.NaN : Number.parseFloat(value);
24
+ return Number.isFinite(parsed) ? parsed : 0;
25
+ };
26
+ /** The offset of an element from the picture's origin: the sum of the translations above it, or null under a scale or a turn. */
27
+ function offsetOf(element) {
28
+ let dx = 0;
29
+ let dy = 0;
30
+ for (let at = element; at !== null; at = at.parentElement) {
31
+ const transform = at.getAttribute('transform');
32
+ if (transform === null || transform.trim() === '')
33
+ continue;
34
+ const match = /^\s*translate\(\s*(-?[\d.]+)(?:[\s,]+(-?[\d.]+))?\s*\)\s*$/.exec(transform);
35
+ if (match === null)
36
+ return null;
37
+ dx += num(match[1] ?? null);
38
+ dy += num(match[2] ?? null);
39
+ }
40
+ return { dx, dy };
41
+ }
42
+ const COMMAND = /[MmLlHhVvCcSsQqTtAaZz]|-?(?:\d+\.?\d*|\.\d+)/g;
43
+ /** How many numbers each command takes, and which of them are x and y pairs (the rest of an arc are radii and flags). */
44
+ const ARITY = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
45
+ /** The box a path's points and control points fall in. A curve never leaves its control points, so the box holds the shape. */
46
+ function pathBox(d) {
47
+ if (!PATH_DATA.test(d))
48
+ return null;
49
+ const xs = [];
50
+ const ys = [];
51
+ let pen = { x: 0, y: 0 };
52
+ let start = pen;
53
+ let command = '';
54
+ let numbers = [];
55
+ const flush = () => {
56
+ const upper = command.toUpperCase();
57
+ const relative = command !== upper;
58
+ const arity = ARITY[upper] ?? 0;
59
+ if (upper === 'Z')
60
+ pen = start;
61
+ for (let index = 0; arity > 0 && index + arity <= numbers.length; index += arity) {
62
+ const n = numbers.slice(index, index + arity);
63
+ const base = relative ? pen : { x: 0, y: 0 };
64
+ const pairs = [];
65
+ if (upper === 'H')
66
+ pairs.push({ x: base.x + (n[0] ?? 0), y: pen.y });
67
+ else if (upper === 'V')
68
+ pairs.push({ x: pen.x, y: base.y + (n[0] ?? 0) });
69
+ else if (upper === 'A')
70
+ pairs.push({ x: base.x + (n[5] ?? 0), y: base.y + (n[6] ?? 0) });
71
+ else
72
+ for (let pair = 0; pair + 1 < n.length; pair += 2)
73
+ pairs.push({ x: base.x + (n[pair] ?? 0), y: base.y + (n[pair + 1] ?? 0) });
74
+ for (const point of pairs) {
75
+ xs.push(point.x);
76
+ ys.push(point.y);
77
+ }
78
+ pen = pairs[pairs.length - 1] ?? pen;
79
+ if (upper === 'M' && index === 0)
80
+ start = pen;
81
+ }
82
+ };
83
+ for (const token of d.match(COMMAND) ?? []) {
84
+ if (/[A-Za-z]/.test(token)) {
85
+ flush();
86
+ command = token;
87
+ numbers = [];
88
+ }
89
+ else
90
+ numbers.push(Number(token));
91
+ }
92
+ flush();
93
+ if (xs.length === 0)
94
+ return null;
95
+ const x = Math.min(...xs);
96
+ const y = Math.min(...ys);
97
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
98
+ }
99
+ /** The box of one shape, in its own coordinates. */
100
+ function shapeBox(shape) {
101
+ const name = shape.localName;
102
+ if (name === 'rect')
103
+ return { x: num(shape.getAttribute('x')), y: num(shape.getAttribute('y')), width: num(shape.getAttribute('width')), height: num(shape.getAttribute('height')) };
104
+ if (name === 'circle' || name === 'ellipse') {
105
+ const rx = num(shape.getAttribute(name === 'circle' ? 'r' : 'rx'));
106
+ const ry = num(shape.getAttribute(name === 'circle' ? 'r' : 'ry'));
107
+ return { x: num(shape.getAttribute('cx')) - rx, y: num(shape.getAttribute('cy')) - ry, width: 2 * rx, height: 2 * ry };
108
+ }
109
+ if (name === 'polygon')
110
+ return pathBox(`M${shape.getAttribute('points') ?? ''}`);
111
+ if (name === 'path')
112
+ return pathBox(shape.getAttribute('d') ?? '');
113
+ return null;
114
+ }
115
+ /** The first shape painted inside a box's group, where it is on the picture, and its corner radius. */
116
+ function boxOf(group) {
117
+ const shapes = group.localName === 'g' ? [...group.querySelectorAll(SHAPES)] : [group];
118
+ for (const shape of shapes) {
119
+ const offset = offsetOf(shape);
120
+ const box = offset === null ? null : shapeBox(shape);
121
+ if (offset === null || box === null || box.width <= 0 || box.height <= 0)
122
+ continue;
123
+ const radius = shape.localName === 'rect' ? num(shape.getAttribute('rx')) : shape.localName === 'path' || shape.localName === 'polygon' ? 0 : box.width / 2;
124
+ return { box: { ...box, x: box.x + offset.dx, y: box.y + offset.dy }, radius };
125
+ }
126
+ return null;
127
+ }
128
+ /** The paths that draw a line, each checked and placed. A path whose data is not plain path data is left out. */
129
+ function pathsOf(line) {
130
+ const paths = line.localName === 'path' ? [line] : [...line.querySelectorAll('path')];
131
+ const out = [];
132
+ for (const path of paths) {
133
+ const d = path.getAttribute('d') ?? '';
134
+ const offset = offsetOf(path);
135
+ if (offset === null || d === '' || !PATH_DATA.test(d))
136
+ continue;
137
+ const fill = path.getAttribute('fill');
138
+ out.push({ d, ...offset, filled: fill !== null && fill !== 'none' });
139
+ }
140
+ return out;
141
+ }
142
+ /** The two ends a line names, before they are matched to boxes. */
143
+ function endsOf(line) {
144
+ const from = line.getAttribute('data-from');
145
+ const to = line.getAttribute('data-to');
146
+ if (from !== null && to !== null)
147
+ return { from, to, numbered: false };
148
+ const linkFrom = line.getAttribute('data-link-from');
149
+ const linkTo = line.getAttribute('data-link-to');
150
+ if (linkFrom !== null && linkTo !== null)
151
+ return { from: linkFrom, to: linkTo, numbered: true };
152
+ /* A Gantt link names its ends in its id, the task before and the task after a `>`. */
153
+ const pair = (line.getAttribute('data-link') ?? '').split('>');
154
+ if (pair.length === 2 && pair[0] !== '' && pair[1] !== '')
155
+ return { from: pair[0], to: pair[1], numbered: false };
156
+ return null;
157
+ }
158
+ /** The name of a state's mark that has no words (start, end, choice, history, fork, join), as a word a person reads. */
159
+ const markName = (group) => {
160
+ const mark = group.getAttribute('data-state-marker') ?? '';
161
+ return /^[a-z]+$/.test(mark) ? `${mark[0].toUpperCase()}${mark.slice(1)}` : '';
162
+ };
163
+ const words = (group) => (group.querySelector('text')?.textContent ?? '').replace(/\s+/g, ' ').trim();
164
+ /**
165
+ * The boxes and lines of a painted picture. Null when the text is not an SVG or has no size. A picture whose kind marks no ends
166
+ * gives a map with no lines, which the viewer takes to mean there is nothing to trace.
167
+ */
168
+ export function readTraceMap(svg) {
169
+ const root = parsePicture(svg);
170
+ return root === null ? null : traceMapOf(root);
171
+ }
172
+ /** The root of a picture's text, parsed into a document that is never attached to the page; null when it is not an SVG. */
173
+ export function parsePicture(svg) {
174
+ let document;
175
+ try {
176
+ document = new DOMParser().parseFromString(svg, 'image/svg+xml');
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ const root = document.documentElement;
182
+ return root.localName !== 'svg' || document.getElementsByTagName('parsererror').length > 0 ? null : root;
183
+ }
184
+ /** The trace map of a picture already parsed with `parsePicture`, so a reader that needs more of it parses it once. */
185
+ export function traceMapOf(root) {
186
+ const viewBox = viewBoxOf(root);
187
+ if (viewBox === null)
188
+ return null;
189
+ const nodes = [];
190
+ const byId = new Map();
191
+ const byName = new Map();
192
+ for (const group of root.querySelectorAll(BOX_MARKS.map((mark) => `[${mark}]`).join(', '))) {
193
+ const id = BOX_MARKS.map((mark) => group.getAttribute(mark)).find((value) => value !== null) ?? '';
194
+ const placed = boxOf(group);
195
+ if (id === '' || placed === null || byId.has(id))
196
+ continue;
197
+ const name = words(group) || markName(group) || id;
198
+ const part = partOf(group);
199
+ const node = { id, name, ...placed, ...(part === null ? {} : { part }) };
200
+ nodes.push(node);
201
+ byId.set(id, node);
202
+ if (!byName.has(name))
203
+ byName.set(name, node);
204
+ }
205
+ const resolve = (end, numbered) => numbered ? byId.get(`t${end}`) : (byId.get(end) ?? byName.get(end));
206
+ const labels = new Map();
207
+ for (const group of root.querySelectorAll('[data-edge-label]')) {
208
+ const id = group.getAttribute('data-edge-label') ?? '';
209
+ const placed = boxOf(group);
210
+ if (id !== '' && placed !== null && !labels.has(id))
211
+ labels.set(id, placed.box);
212
+ }
213
+ const lines = [];
214
+ const marked = [...LINE_MARKS.map((mark) => `[${mark}]`), '[data-link-from]'].join(', ');
215
+ root.querySelectorAll(marked).forEach((element, index) => {
216
+ const ends = endsOf(element);
217
+ if (ends === null)
218
+ return;
219
+ const from = resolve(ends.from, ends.numbered);
220
+ const to = resolve(ends.to, ends.numbered);
221
+ const paths = pathsOf(element);
222
+ if (from === undefined || to === undefined || paths.length === 0)
223
+ return;
224
+ const id = LINE_MARKS.map((mark) => element.getAttribute(mark)).find((value) => value !== null) ?? `line-${index}`;
225
+ lines.push({ id, from: from.id, to: to.id, paths, label: labels.get(id) ?? null });
226
+ });
227
+ return { viewBox, nodes, lines };
228
+ }
229
+ /** A box's `data-part`: a whole number from 1, or null for none or anything else. */
230
+ function partOf(group) {
231
+ const value = group.getAttribute('data-part') ?? '';
232
+ return /^[1-9]\d{0,2}$/.test(value) ? Number(value) : null;
233
+ }
234
+ function viewBoxOf(root) {
235
+ const box = (root.getAttribute('viewBox') ?? '').trim().split(/[\s,]+/).map(Number);
236
+ if (box.length === 4 && box.every(Number.isFinite) && box[2] > 0 && box[3] > 0) {
237
+ return { x: box[0], y: box[1], width: box[2], height: box[3] };
238
+ }
239
+ const width = num(root.getAttribute('width'));
240
+ const height = num(root.getAttribute('height'));
241
+ return width > 0 && height > 0 ? { x: 0, y: 0, width, height } : null;
242
+ }
243
+ /** The boxes that have at least one line, in reading order: top to bottom, then left to right. These are the ones a person can trace. */
244
+ export function traceableNodes(map) {
245
+ const ends = new Set(map.lines.flatMap((line) => [line.from, line.to]));
246
+ return map.nodes.filter((node) => ends.has(node.id)).sort((a, b) => a.box.y - b.box.y || a.box.x - b.box.x);
247
+ }
248
+ /** A box's lines, each from that box's side. Null for a box with no lines or no such box. */
249
+ export function traceOf(map, id) {
250
+ const node = map.nodes.find((candidate) => candidate.id === id);
251
+ if (node === undefined)
252
+ return null;
253
+ const byId = new Map(map.nodes.map((candidate) => [candidate.id, candidate]));
254
+ const lines = [];
255
+ for (const line of map.lines) {
256
+ if (line.from === id)
257
+ lines.push({ line, direction: 'to', other: byId.get(line.to) });
258
+ else if (line.to === id)
259
+ lines.push({ line, direction: 'from', other: byId.get(line.from) });
260
+ }
261
+ return lines.length === 0 ? null : { node, lines };
262
+ }
263
+ /** The traceable box under a point of the picture: the smallest one that holds it, so a box inside a box is reachable. */
264
+ export function nodeAt(map, point) {
265
+ let best = null;
266
+ for (const node of traceableNodes(map)) {
267
+ const { x, y, width, height } = node.box;
268
+ if (point.x < x || point.y < y || point.x > x + width || point.y > y + height)
269
+ continue;
270
+ if (best === null || width * height < best.box.width * best.box.height)
271
+ best = node;
272
+ }
273
+ return best;
274
+ }
275
+ /** The box under a point of the picture that opens a part of its series (`part=`): the smallest one that holds it. */
276
+ export function drillNodeAt(map, point) {
277
+ let best = null;
278
+ for (const node of map.nodes) {
279
+ if (node.part === undefined)
280
+ continue;
281
+ const { x, y, width, height } = node.box;
282
+ if (point.x < x || point.y < y || point.x > x + width || point.y > y + height)
283
+ continue;
284
+ if (best === null || width * height < best.box.width * best.box.height)
285
+ best = node;
286
+ }
287
+ return best;
288
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Which colour scheme a picture is shown in. A Plotwork picture drawn with `scheme=auto` carries both palettes and follows the
3
+ * reader's settings; a host forces one with `data-scheme="light"` or `"dark"` on its root, which the engine's style sheet reads.
4
+ * A picture drawn for one scheme says so in `data-theme` and ignores the attribute.
5
+ */
6
+ /** What the viewer is asked to show: follow the page (`auto`), or one scheme. */
7
+ export type ThemeChoice = 'auto' | 'light' | 'dark';
8
+ /** A scheme a picture is actually drawn in. */
9
+ export type Scheme = 'light' | 'dark';
10
+ /** Reads a `theme` attribute: anything that is not `light` or `dark` is `auto`. */
11
+ export declare function themeChoiceOf(value: string | null | undefined): ThemeChoice;
12
+ /**
13
+ * The picture's text with `data-scheme` set on its root for a forced scheme, or as it was for `auto`. It edits the start tag as
14
+ * text, so the picture is never parsed and written out again, and an existing `data-scheme` is replaced, never doubled.
15
+ */
16
+ export declare function withScheme(svg: string, choice: ThemeChoice): string;
17
+ /** The scheme a picture was drawn in when it was drawn for one (`data-theme="light"` or `"dark"`), else null: it follows the scheme it is shown in. */
18
+ export declare function drawnScheme(svg: string): Scheme | null;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Which colour scheme a picture is shown in. A Plotwork picture drawn with `scheme=auto` carries both palettes and follows the
3
+ * reader's settings; a host forces one with `data-scheme="light"` or `"dark"` on its root, which the engine's style sheet reads.
4
+ * A picture drawn for one scheme says so in `data-theme` and ignores the attribute.
5
+ */
6
+ /** Reads a `theme` attribute: anything that is not `light` or `dark` is `auto`. */
7
+ export function themeChoiceOf(value) {
8
+ return value === 'light' || value === 'dark' ? value : 'auto';
9
+ }
10
+ /** The root `<svg ...>` start tag, after anything that may come before it. */
11
+ const ROOT_TAG = /<svg(?=[\s>])[^>]*>/;
12
+ /**
13
+ * The picture's text with `data-scheme` set on its root for a forced scheme, or as it was for `auto`. It edits the start tag as
14
+ * text, so the picture is never parsed and written out again, and an existing `data-scheme` is replaced, never doubled.
15
+ */
16
+ export function withScheme(svg, choice) {
17
+ if (choice === 'auto')
18
+ return svg;
19
+ const match = ROOT_TAG.exec(svg);
20
+ if (match === null)
21
+ return svg;
22
+ const tag = match[0].replace(/\sdata-scheme="[^"]*"/, '');
23
+ const marked = tag.replace(/^<svg/, `<svg data-scheme="${choice}"`);
24
+ return svg.slice(0, match.index) + marked + svg.slice(match.index + match[0].length);
25
+ }
26
+ /** The scheme a picture was drawn in when it was drawn for one (`data-theme="light"` or `"dark"`), else null: it follows the scheme it is shown in. */
27
+ export function drawnScheme(svg) {
28
+ const tag = ROOT_TAG.exec(svg)?.[0] ?? '';
29
+ const theme = /\sdata-theme="([^"]*)"/.exec(tag)?.[1];
30
+ return theme === 'light' || theme === 'dark' ? theme : null;
31
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Reading the source out of a Plotwork picture, with no engine. A picture saved with its source carries the text it was drawn from in
3
+ * one `<metadata data-format="buun-diagram">` element of its root; this reads it back with patterns, as the engine's own reader does,
4
+ * so it needs no XML parser. Kept equal to the portal's reader (`apps/app/src/lib/picture-source.ts`). A mistake is a result, never
5
+ * an exception.
6
+ */
7
+ /** What reading a picture's source found. */
8
+ export type SourceReading =
9
+ /** The source, exactly as it was written into the picture. */
10
+ {
11
+ status: 'found';
12
+ source: string;
13
+ }
14
+ /** A picture that carries no source: it was saved without it, or served by an organisation's embed link, which removes it. */
15
+ | {
16
+ status: 'none';
17
+ }
18
+ /** Not a picture, or a source that was changed after it was written and cannot be read back. */
19
+ | {
20
+ status: 'unreadable';
21
+ };
22
+ /** The source a picture carries, a picture that carries none, or a text that cannot be read as either. */
23
+ export declare function readEmbeddedSource(svg: string): SourceReading;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reading the source out of a Plotwork picture, with no engine. A picture saved with its source carries the text it was drawn from in
3
+ * one `<metadata data-format="buun-diagram">` element of its root; this reads it back with patterns, as the engine's own reader does,
4
+ * so it needs no XML parser. Kept equal to the portal's reader (`apps/app/src/lib/picture-source.ts`). A mistake is a result, never
5
+ * an exception.
6
+ */
7
+ const SOURCE_FORMAT = 'buun-diagram';
8
+ const OPEN = `<metadata data-format="${SOURCE_FORMAT}"`;
9
+ const CLOSE = '</metadata>';
10
+ /** What can come before the root element of a saved SVG: a byte order mark, white space, an XML declaration, comments, and a doctype. */
11
+ const PROLOGUE = /^(?:|\s|<\?xml[^>]*\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>)*/i;
12
+ function fromRoot(svg) {
13
+ const rest = svg.slice(PROLOGUE.exec(svg)?.[0].length ?? 0);
14
+ return /^<svg[\s>]/.test(rest) ? rest : null;
15
+ }
16
+ const ESCAPE = /&(amp|lt|gt|#13);/g;
17
+ const UNESCAPED = { amp: '&', lt: '<', gt: '>', '#13': '\r' };
18
+ /** Reverses the writer's escaping; null when the text has a `&` that is not one of its four escapes, or a `<`, which the writer never leaves. */
19
+ function unescapeSource(text) {
20
+ if (text.includes('<') || text.replace(ESCAPE, '').includes('&'))
21
+ return null;
22
+ return text.replace(ESCAPE, (_whole, name) => UNESCAPED[name] ?? '');
23
+ }
24
+ /** The source a picture carries, a picture that carries none, or a text that cannot be read as either. */
25
+ export function readEmbeddedSource(svg) {
26
+ const root = fromRoot(svg);
27
+ if (root === null)
28
+ return { status: 'unreadable' };
29
+ const start = root.indexOf(OPEN);
30
+ if (start === -1)
31
+ return { status: 'none' };
32
+ const tagEnd = root.indexOf('>', start);
33
+ if (tagEnd === -1)
34
+ return { status: 'unreadable' };
35
+ const tag = root.slice(start, tagEnd);
36
+ if (tag.endsWith('/'))
37
+ return { status: 'unreadable' };
38
+ const close = root.indexOf(CLOSE, tagEnd);
39
+ if (close === -1)
40
+ return { status: 'unreadable' };
41
+ const text = unescapeSource(root.slice(tagEnd + 1, close));
42
+ if (text === null)
43
+ return { status: 'unreadable' };
44
+ const encoding = /\sdata-encoding="([^"]*)"/.exec(tag)?.[1] ?? 'text';
45
+ if (encoding === 'text')
46
+ return text === '' ? { status: 'unreadable' } : { status: 'found', source: text };
47
+ if (encoding !== 'json')
48
+ return { status: 'unreadable' };
49
+ try {
50
+ const decoded = JSON.parse(text);
51
+ return typeof decoded === 'string' && decoded !== '' ? { status: 'found', source: decoded } : { status: 'unreadable' };
52
+ }
53
+ catch {
54
+ // The JSON was cut or edited after it was written: that is the answer (unreadable), not an error to throw.
55
+ return { status: 'unreadable' };
56
+ }
57
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Every word the element shows or says, in one place. A page in another language sets the element's `labels` property to a partial
3
+ * copy of this object; anything it leaves out stays English.
4
+ */
5
+ export interface ViewerLabels {
6
+ canvas: string;
7
+ zoomIn: string;
8
+ zoomOut: string;
9
+ fit: string;
10
+ fullscreen: string;
11
+ exitFullscreen: string;
12
+ zoomLevel: (percent: number) => string;
13
+ loading: string;
14
+ wheelHint: string;
15
+ traced: (name: string, lines: number) => string;
16
+ drill: (name: string) => string;
17
+ updated: string;
18
+ failed: Readonly<Record<'address' | 'missing' | 'not-a-picture' | 'network' | 'fingerprint' | 'empty', string>>;
19
+ }
20
+ export declare const DEFAULT_LABELS: ViewerLabels;
21
+ /** The labels with a page's own over the defaults. */
22
+ export declare function mergeLabels(own: Partial<ViewerLabels> | undefined): ViewerLabels;
@@ -0,0 +1,28 @@
1
+ export const DEFAULT_LABELS = {
2
+ canvas: 'Diagram. Arrow keys move it, plus and minus zoom, 0 fits it.',
3
+ zoomIn: 'Zoom in',
4
+ zoomOut: 'Zoom out',
5
+ fit: 'Fit to view',
6
+ fullscreen: 'Full screen',
7
+ exitFullscreen: 'Leave full screen',
8
+ zoomLevel: (percent) => `${percent}%`,
9
+ loading: 'Loading the diagram',
10
+ wheelHint: 'Hold Control or Command and scroll to zoom',
11
+ traced: (name, lines) => `${name}: ${lines} ${lines === 1 ? 'line' : 'lines'}`,
12
+ drill: (name) => `${name} opens its part`,
13
+ updated: 'The diagram was updated',
14
+ failed: {
15
+ address: 'This diagram address cannot be shown.',
16
+ missing: 'This diagram is not there.',
17
+ 'not-a-picture': 'This is not a Plotwork diagram.',
18
+ network: 'The diagram could not be loaded. Check the connection and the page settings.',
19
+ fingerprint: 'This diagram has changed since the page pinned it, so it is not shown.',
20
+ empty: 'No diagram to show.',
21
+ },
22
+ };
23
+ /** The labels with a page's own over the defaults. */
24
+ export function mergeLabels(own) {
25
+ if (own === undefined)
26
+ return DEFAULT_LABELS;
27
+ return { ...DEFAULT_LABELS, ...own, failed: { ...DEFAULT_LABELS.failed, ...own.failed } };
28
+ }