@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,397 @@
1
+ import { clampView, dotGridStyle, fitView, isFitView, middleOf, percentOf, zoomAt } from '../core/canvas-view.js';
2
+ import { fingerprintOf, isFingerprint } from '../core/fingerprint.js';
3
+ import { VIEWER_ZOOM_STEP } from '../core/limits.js';
4
+ import { documentHost, liveIntervalOf, watchLive } from '../core/live-watch.js';
5
+ import { drillNodeAt, nodeAt, parsePicture, traceMapOf, traceOf } from '../core/picture-map.js';
6
+ import { loadPicture, pictureUrl, PictureLoadError } from '../core/picture-loader.js';
7
+ import { drawnScheme, themeChoiceOf, withScheme } from '../core/picture-scheme.js';
8
+ import { readEmbeddedSource } from '../core/picture-source.js';
9
+ import { mergeLabels } from './copy.js';
10
+ import { emit } from './events.js';
11
+ import { attachGestures } from './gestures.js';
12
+ import { buildShell } from './shell.js';
13
+ import { place, traceLayer } from './trace-layer.js';
14
+ /** The attributes the element reads. */
15
+ export const OBSERVED_ATTRIBUTES = ['src', 'theme', 'live', 'interval', 'fingerprint', 'wheel', 'label'];
16
+ /** The element's class, made on first use so that importing the package where there is no `HTMLElement` (a server render) is safe. */
17
+ export function createPlotworkDiagramClass() {
18
+ return class PlotworkDiagram extends HTMLElement {
19
+ static get observedAttributes() {
20
+ return OBSERVED_ATTRIBUTES;
21
+ }
22
+ #shell;
23
+ #labels = mergeLabels(undefined);
24
+ #ownLabels;
25
+ #svg = null;
26
+ #text = null;
27
+ #fingerprint = null;
28
+ #map = null;
29
+ #size = null;
30
+ #view = { scale: 1, x: 0, y: 0 };
31
+ /** The canvas the view was last set for, and whether the picture is still to be fitted once the canvas has a size. */
32
+ #viewport = null;
33
+ #pendingFit = true;
34
+ #blob = null;
35
+ #traced = null;
36
+ #layer = null;
37
+ #loading = null;
38
+ #stopLive = null;
39
+ #cleanup = [];
40
+ #hintTimer;
41
+ constructor() {
42
+ super();
43
+ this.#shell = buildShell(this.attachShadow({ mode: 'open' }), this.#labels);
44
+ }
45
+ connectedCallback() {
46
+ /* A property set before the element was defined sits on the instance and hides the accessor: move it onto the accessor. */
47
+ for (const name of ['labels', 'svg']) {
48
+ if (!Object.hasOwn(this, name))
49
+ continue;
50
+ const value = this[name];
51
+ delete this[name];
52
+ this[name] = value;
53
+ }
54
+ const { canvas, zoomIn, zoomOut, fit, fullscreen } = this.#shell;
55
+ this.#cleanup.push(attachGestures(canvas, {
56
+ view: () => this.#view,
57
+ setView: (view, glide) => this.#setView(view, glide),
58
+ sizes: () => this.#sizes(),
59
+ wheelMode: () => this.#wheelMode(),
60
+ hover: (point) => this.#hover(point),
61
+ press: (point) => this.#press(point),
62
+ hintWheel: () => this.#hintWheel(),
63
+ fit: () => this.fit(),
64
+ release: () => this.#trace(null),
65
+ }));
66
+ const on = (target, type, listener) => {
67
+ target.addEventListener(type, listener);
68
+ this.#cleanup.push(() => target.removeEventListener(type, listener));
69
+ };
70
+ on(zoomIn, 'click', () => this.zoomIn());
71
+ on(zoomOut, 'click', () => this.zoomOut());
72
+ on(fit, 'click', () => this.fit());
73
+ on(fullscreen, 'click', () => void this.#toggleFullscreen());
74
+ on(document, 'fullscreenchange', () => this.#syncFullscreen());
75
+ const resize = new ResizeObserver(() => this.#resized());
76
+ resize.observe(canvas);
77
+ this.#cleanup.push(() => resize.disconnect());
78
+ const dark = matchMedia('(prefers-color-scheme: dark)');
79
+ on(dark, 'change', () => this.#paintPaper());
80
+ void this.#refresh();
81
+ }
82
+ disconnectedCallback() {
83
+ for (const undo of this.#cleanup.splice(0))
84
+ undo();
85
+ this.#loading?.abort();
86
+ this.#stopWatching();
87
+ clearTimeout(this.#hintTimer);
88
+ }
89
+ attributeChangedCallback(name, before, after) {
90
+ if (before === after || !this.isConnected)
91
+ return;
92
+ if (name === 'label')
93
+ this.#describe();
94
+ else if (name === 'wheel')
95
+ return;
96
+ else if (name === 'theme' && this.#text !== null)
97
+ this.#paintPicture(this.#text);
98
+ else if (name === 'live' || name === 'interval')
99
+ this.#watch();
100
+ else
101
+ void this.#refresh();
102
+ }
103
+ get svg() {
104
+ return this.#svg;
105
+ }
106
+ set svg(value) {
107
+ this.#svg = typeof value === 'string' && value !== '' ? value : null;
108
+ if (this.isConnected)
109
+ void this.#refresh();
110
+ }
111
+ get labels() {
112
+ return this.#ownLabels;
113
+ }
114
+ set labels(value) {
115
+ this.#ownLabels = value;
116
+ this.#labels = mergeLabels(value);
117
+ const { zoomIn, zoomOut, fit, hint } = this.#shell;
118
+ for (const [button, label] of [[zoomIn, this.#labels.zoomIn], [zoomOut, this.#labels.zoomOut], [fit, this.#labels.fit]]) {
119
+ button.setAttribute('aria-label', label);
120
+ button.title = label;
121
+ }
122
+ hint.textContent = this.#labels.wheelHint;
123
+ this.#syncFullscreen();
124
+ this.#describe();
125
+ }
126
+ get fingerprint() {
127
+ return this.#fingerprint;
128
+ }
129
+ source() {
130
+ if (this.#text === null)
131
+ return null;
132
+ const reading = readEmbeddedSource(this.#text);
133
+ return reading.status === 'found' ? reading.source : null;
134
+ }
135
+ fit() {
136
+ const sizes = this.#sizes();
137
+ if (sizes === null)
138
+ return;
139
+ this.#pendingFit = false;
140
+ this.#setView(fitView(sizes.viewport, sizes.picture), true);
141
+ }
142
+ zoomIn() {
143
+ this.#zoomBy(VIEWER_ZOOM_STEP);
144
+ }
145
+ zoomOut() {
146
+ this.#zoomBy(1 / VIEWER_ZOOM_STEP);
147
+ }
148
+ async reload() {
149
+ await this.#refresh(true);
150
+ }
151
+ /* Loading. */
152
+ async #refresh(revalidate = false) {
153
+ this.#loading?.abort();
154
+ const loading = new AbortController();
155
+ this.#loading = loading;
156
+ try {
157
+ const text = this.#svg ?? (await this.#fetch(loading.signal, revalidate));
158
+ if (loading.signal.aborted)
159
+ return;
160
+ if (text === null)
161
+ return this.#fail('empty');
162
+ await this.#show(text, revalidate && this.#fingerprint !== null);
163
+ }
164
+ catch (error) {
165
+ if (!loading.signal.aborted)
166
+ this.#fail(error instanceof PictureLoadError ? error.reason : 'network');
167
+ }
168
+ this.#watch();
169
+ }
170
+ async #fetch(signal, revalidate) {
171
+ const src = this.getAttribute('src');
172
+ if (src === null || src.trim() === '')
173
+ return null;
174
+ const url = pictureUrl(src, document.baseURI);
175
+ if (url === null)
176
+ throw new PictureLoadError('address');
177
+ return loadPicture(url, { signal, revalidate });
178
+ }
179
+ /** Shows a picture's text. An update keeps the view when the picture is the same size; a first showing fits it. */
180
+ async #show(text, update) {
181
+ const fingerprint = await fingerprintOf(text);
182
+ const pinned = this.getAttribute('fingerprint');
183
+ if (pinned !== null && isFingerprint(pinned) && pinned !== fingerprint)
184
+ throw new PictureLoadError('fingerprint');
185
+ if (update && fingerprint === this.#fingerprint)
186
+ return;
187
+ const root = parsePicture(text);
188
+ const map = root === null ? null : traceMapOf(root);
189
+ if (map === null)
190
+ throw new PictureLoadError('not-a-picture');
191
+ const previous = this.#fingerprint;
192
+ const size = { width: map.viewBox.width, height: map.viewBox.height };
193
+ const sameSize = this.#size !== null && this.#size.width === size.width && this.#size.height === size.height;
194
+ this.#text = text;
195
+ this.#fingerprint = fingerprint;
196
+ this.#map = map;
197
+ this.#size = size;
198
+ this.#traced = null;
199
+ this.#paintPicture(text);
200
+ const { picture, canvas, notice, toolbar } = this.#shell;
201
+ picture.hidden = false;
202
+ notice.hidden = true;
203
+ toolbar.hidden = false;
204
+ canvas.setAttribute('aria-busy', 'false');
205
+ this.#describe();
206
+ const sizes = this.#sizes();
207
+ this.#pendingFit = !(update && sameSize);
208
+ if (sizes === null)
209
+ return void this.#announceShown(update, previous, fingerprint, text);
210
+ if (this.#pendingFit)
211
+ this.fit();
212
+ else
213
+ this.#setView(clampView(this.#view, sizes.viewport, sizes.picture));
214
+ this.#drawTrace();
215
+ this.#announceShown(update, previous, fingerprint, text);
216
+ }
217
+ #announceShown(update, previous, fingerprint, text) {
218
+ if (update && previous !== null) {
219
+ this.#say(this.#labels.updated);
220
+ emit(this, 'plotwork-change', { fingerprint, previous });
221
+ }
222
+ else
223
+ emit(this, 'plotwork-load', { fingerprint, hasSource: readEmbeddedSource(text).status === 'found' });
224
+ }
225
+ /** Points the `img` at the picture in the scheme asked for, and paints the paper to match. */
226
+ #paintPicture(text) {
227
+ this.#paintPaper();
228
+ const blob = URL.createObjectURL(new Blob([withScheme(text, themeChoiceOf(this.getAttribute('theme')))], { type: 'image/svg+xml' }));
229
+ this.#shell.picture.src = blob;
230
+ if (this.#blob !== null)
231
+ URL.revokeObjectURL(this.#blob);
232
+ this.#blob = blob;
233
+ }
234
+ #fail(reason) {
235
+ const { picture, notice, toolbar, canvas } = this.#shell;
236
+ this.#text = null;
237
+ this.#fingerprint = null;
238
+ this.#map = null;
239
+ this.#size = null;
240
+ picture.hidden = true;
241
+ picture.removeAttribute('src');
242
+ if (this.#blob !== null)
243
+ URL.revokeObjectURL(this.#blob);
244
+ this.#blob = null;
245
+ notice.textContent = this.#labels.failed[reason];
246
+ notice.hidden = false;
247
+ toolbar.hidden = true;
248
+ canvas.setAttribute('aria-busy', 'false');
249
+ this.#drawTrace();
250
+ emit(this, 'plotwork-error', { reason });
251
+ }
252
+ /** Watches a live picture: only with `live`, a `src`, a picture shown, and no pinned fingerprint. */
253
+ #watch() {
254
+ this.#stopWatching();
255
+ if (!this.hasAttribute('live') || this.#svg !== null || this.hasAttribute('fingerprint') || this.#fingerprint === null)
256
+ return;
257
+ this.#stopLive = watchLive(documentHost(document), liveIntervalOf(this.getAttribute('interval')), async () => {
258
+ const text = await this.#fetch(new AbortController().signal, true);
259
+ if (text !== null)
260
+ await this.#show(text, true);
261
+ });
262
+ }
263
+ #stopWatching() {
264
+ this.#stopLive?.();
265
+ this.#stopLive = null;
266
+ }
267
+ /* The canvas. */
268
+ #sizes() {
269
+ if (this.#size === null)
270
+ return null;
271
+ const { clientWidth: width, clientHeight: height } = this.#shell.canvas;
272
+ return width > 0 && height > 0 ? { viewport: { width, height }, picture: this.#size } : null;
273
+ }
274
+ #wheelMode() {
275
+ return document.fullscreenElement === this || this.getAttribute('wheel') === 'zoom' ? 'zoom' : 'modifier';
276
+ }
277
+ #setView(view, glide = false) {
278
+ this.#view = view;
279
+ this.#viewport = this.#sizes()?.viewport ?? this.#viewport;
280
+ const { picture, zoomLevel } = this.#shell;
281
+ for (const element of [picture, this.#layer]) {
282
+ if (element === null || this.#size === null)
283
+ continue;
284
+ element.toggleAttribute('data-glide', glide);
285
+ place(element, this.#size, view);
286
+ }
287
+ zoomLevel.value = this.#labels.zoomLevel(percentOf(view.scale));
288
+ const grid = dotGridStyle(view);
289
+ this.#shell.canvas.style.backgroundSize = grid.backgroundSize;
290
+ this.#shell.canvas.style.backgroundPosition = grid.backgroundPosition;
291
+ }
292
+ #zoomBy(factor) {
293
+ const sizes = this.#sizes();
294
+ if (sizes !== null)
295
+ this.#setView(zoomAt(this.#view, middleOf(sizes.viewport), this.#view.scale * factor, sizes.viewport, sizes.picture), true);
296
+ }
297
+ #resized() {
298
+ const sizes = this.#sizes();
299
+ if (sizes === null)
300
+ return;
301
+ const wasFitted = this.#viewport !== null && isFitView(this.#view, this.#viewport, sizes.picture);
302
+ if (this.#pendingFit || wasFitted) {
303
+ this.#pendingFit = false;
304
+ this.#setView(fitView(sizes.viewport, sizes.picture));
305
+ }
306
+ else
307
+ this.#setView(clampView(this.#view, sizes.viewport, sizes.picture));
308
+ }
309
+ /** A canvas point in the picture's own units. */
310
+ #toPicture(point) {
311
+ if (this.#map === null)
312
+ return null;
313
+ return { x: (point.x - this.#view.x) / this.#view.scale + this.#map.viewBox.x, y: (point.y - this.#view.y) / this.#view.scale + this.#map.viewBox.y };
314
+ }
315
+ #hover(point) {
316
+ const at = point === null ? null : this.#toPicture(point);
317
+ const map = this.#map;
318
+ this.#shell.canvas.toggleAttribute('data-drill', at !== null && map !== null && drillNodeAt(map, at) !== null);
319
+ if (this.#traced?.pinned === true)
320
+ return;
321
+ this.#trace(at === null || map === null ? null : (nodeAt(map, at)?.id ?? null), false);
322
+ }
323
+ #press(point) {
324
+ const at = this.#toPicture(point);
325
+ const map = this.#map;
326
+ if (at === null || map === null)
327
+ return;
328
+ const drill = drillNodeAt(map, at);
329
+ if (drill?.part !== undefined) {
330
+ emit(this, 'plotwork-drill', { id: drill.id, part: drill.part, name: drill.name });
331
+ this.#say(this.#labels.drill(drill.name));
332
+ return;
333
+ }
334
+ const node = nodeAt(map, at);
335
+ if (node === null || (this.#traced?.pinned === true && this.#traced.id === node.id))
336
+ this.#trace(null);
337
+ else
338
+ this.#trace(node.id, true);
339
+ }
340
+ #trace(id, pinned = false) {
341
+ if ((this.#traced?.id ?? null) === id && (this.#traced?.pinned ?? false) === pinned)
342
+ return;
343
+ this.#traced = id === null ? null : { id, pinned };
344
+ this.#drawTrace();
345
+ emit(this, 'plotwork-trace', { id });
346
+ }
347
+ #drawTrace() {
348
+ this.#layer?.remove();
349
+ this.#layer = null;
350
+ const trace = this.#traced === null || this.#map === null ? null : traceOf(this.#map, this.#traced.id);
351
+ if (trace === null || this.#map === null || this.#size === null)
352
+ return;
353
+ this.#layer = traceLayer(this.#map, trace);
354
+ place(this.#layer, this.#size, this.#view);
355
+ this.#shell.canvas.append(this.#layer);
356
+ if (this.#traced?.pinned === true)
357
+ this.#say(this.#labels.traced(trace.node.name, trace.lines.length));
358
+ }
359
+ /* Everything else the element shows. */
360
+ /** The paper behind the picture: the scheme it was drawn in, else the one asked for, else the page's. */
361
+ #paintPaper() {
362
+ const choice = themeChoiceOf(this.getAttribute('theme'));
363
+ const drawn = this.#text === null ? null : drawnScheme(this.#text);
364
+ const paper = drawn ?? (choice === 'auto' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : choice);
365
+ this.setAttribute('data-paper', paper);
366
+ }
367
+ #describe() {
368
+ const label = this.getAttribute('label');
369
+ this.#shell.canvas.setAttribute('aria-label', label === null || label.trim() === '' ? this.#labels.canvas : `${label}. ${this.#labels.canvas}`);
370
+ }
371
+ #say(text) {
372
+ this.#shell.live.textContent = text;
373
+ }
374
+ #hintWheel() {
375
+ const { hint } = this.#shell;
376
+ hint.setAttribute('data-shown', '');
377
+ clearTimeout(this.#hintTimer);
378
+ this.#hintTimer = setTimeout(() => hint.removeAttribute('data-shown'), 1500);
379
+ }
380
+ async #toggleFullscreen() {
381
+ try {
382
+ if (document.fullscreenElement === this)
383
+ await document.exitFullscreen();
384
+ else
385
+ await this.requestFullscreen();
386
+ }
387
+ catch {
388
+ // Full screen refused (an iframe without permission): the button simply does nothing.
389
+ }
390
+ }
391
+ #syncFullscreen() {
392
+ const label = document.fullscreenElement === this ? this.#labels.exitFullscreen : this.#labels.fullscreen;
393
+ this.#shell.fullscreen.setAttribute('aria-label', label);
394
+ this.#shell.fullscreen.title = label;
395
+ }
396
+ };
397
+ }
@@ -0,0 +1,22 @@
1
+ import type { ViewerLabels } from './copy.js';
2
+ /**
3
+ * The element's shadow tree: the stage, the canvas the picture moves on, the picture as an `img`, the toolbar, the notice for a
4
+ * picture that cannot be shown, the wheel hint, and a polite live region. Built once when the element is made; the element changes
5
+ * attributes and text on these nodes and never rebuilds them.
6
+ */
7
+ export interface Shell {
8
+ stage: HTMLDivElement;
9
+ canvas: HTMLDivElement;
10
+ picture: HTMLImageElement;
11
+ toolbar: HTMLDivElement;
12
+ zoomOut: HTMLButtonElement;
13
+ zoomIn: HTMLButtonElement;
14
+ zoomLevel: HTMLOutputElement;
15
+ fit: HTMLButtonElement;
16
+ fullscreen: HTMLButtonElement;
17
+ notice: HTMLParagraphElement;
18
+ hint: HTMLParagraphElement;
19
+ live: HTMLDivElement;
20
+ }
21
+ /** Builds the shadow tree into a shadow root. */
22
+ export declare function buildShell(root: ShadowRoot, labels: ViewerLabels): Shell;
@@ -0,0 +1,30 @@
1
+ import { html, icon } from './dom.js';
2
+ import { VIEWER_CSS } from './styles.js';
3
+ const ICONS = {
4
+ zoomOut: ['M5 10h10'],
5
+ zoomIn: ['M5 10h10', 'M10 5v10'],
6
+ fit: ['M4 8V4h4', 'M12 4h4v4', 'M16 12v4h-4', 'M8 16H4v-4'],
7
+ fullscreen: ['M3 7V3h4', 'M13 3h4v4', 'M17 13v4h-4', 'M7 17H3v-4', 'M7 7h6v6H7z'],
8
+ };
9
+ function button(label, paths) {
10
+ return html('button', { type: 'button', part: 'button', 'aria-label': label, title: label }, icon(paths));
11
+ }
12
+ /** Builds the shadow tree into a shadow root. */
13
+ export function buildShell(root, labels) {
14
+ const style = html('style');
15
+ style.textContent = VIEWER_CSS;
16
+ const picture = html('img', { class: 'picture', alt: '', draggable: 'false', decoding: 'async' });
17
+ const canvas = html('div', { class: 'canvas', part: 'canvas', tabindex: 0, role: 'img', 'aria-roledescription': 'diagram', 'aria-busy': 'true' }, picture);
18
+ const zoomOut = button(labels.zoomOut, ICONS.zoomOut);
19
+ const zoomIn = button(labels.zoomIn, ICONS.zoomIn);
20
+ const zoomLevel = html('output', { 'aria-hidden': 'true' });
21
+ const fit = button(labels.fit, ICONS.fit);
22
+ const fullscreen = button(labels.fullscreen, ICONS.fullscreen);
23
+ const toolbar = html('div', { class: 'toolbar', part: 'toolbar', role: 'toolbar', hidden: '' }, zoomOut, zoomLevel, zoomIn, fit, fullscreen);
24
+ const notice = html('p', { class: 'notice', part: 'notice', role: 'status' }, labels.loading);
25
+ const hint = html('p', { class: 'hint', 'aria-hidden': 'true' }, labels.wheelHint);
26
+ const live = html('div', { class: 'live', 'aria-live': 'polite' });
27
+ const stage = html('div', { class: 'stage', part: 'stage' }, canvas, toolbar, notice, hint, live);
28
+ root.append(style, stage);
29
+ return { stage, canvas, picture, toolbar, zoomOut, zoomIn, zoomLevel, fit, fullscreen, notice, hint, live };
30
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The element's style sheet, inside its shadow root, so a page's CSS never reaches in and the element's never leaks out. A page
3
+ * themes it through the custom properties on `:host` (`--plotwork-paper`, `--plotwork-ink`, `--plotwork-trace`, `--plotwork-radius`,
4
+ * `--plotwork-font`, `--plotwork-height`) and styles the parts named with `part` (`stage`, `canvas`, `toolbar`, `button`, `notice`).
5
+ */
6
+ export declare const VIEWER_CSS = "\n:host {\n --plotwork-paper: #f4f4f1;\n --plotwork-ink: #52514e;\n --plotwork-trace: #0f766e;\n --plotwork-control: #ffffff;\n --plotwork-border: color-mix(in srgb, var(--plotwork-ink) 18%, transparent);\n --plotwork-dot: color-mix(in srgb, var(--plotwork-ink) 22%, transparent);\n --plotwork-radius: 10px;\n --plotwork-font: system-ui, -apple-system, 'Segoe UI', sans-serif;\n --plotwork-height: 480px;\n display: block;\n position: relative;\n block-size: var(--plotwork-height);\n min-block-size: 200px;\n color-scheme: light dark;\n font-family: var(--plotwork-font);\n color: var(--plotwork-ink);\n}\n:host([hidden]) { display: none; }\n:host([theme='light']) { color-scheme: light; }\n:host([theme='dark']) { color-scheme: dark; }\n:host([data-paper='dark']) {\n --plotwork-paper: #171716;\n --plotwork-ink: #c3c2b7;\n --plotwork-trace: #2dd4bf;\n --plotwork-control: #262624;\n --plotwork-dot: color-mix(in srgb, var(--plotwork-ink) 16%, transparent);\n}\n.stage {\n position: absolute;\n inset: 0;\n overflow: hidden;\n border: 1px solid var(--plotwork-border);\n border-radius: var(--plotwork-radius);\n background: var(--plotwork-paper);\n}\n.canvas {\n position: absolute;\n inset: 0;\n overflow: hidden;\n touch-action: none;\n cursor: grab;\n background-image: radial-gradient(circle, var(--plotwork-dot) 0.9px, transparent 1.4px);\n outline: none;\n}\n.canvas:focus-visible { box-shadow: inset 0 0 0 2px var(--plotwork-trace); border-radius: var(--plotwork-radius); }\n.canvas[data-dragging] { cursor: grabbing; }\n.canvas[data-drill] { cursor: pointer; }\n.picture, .trace {\n position: absolute;\n inset: 0 auto auto 0;\n display: block;\n max-inline-size: none;\n transform-origin: 0 0;\n user-select: none;\n -webkit-user-drag: none;\n pointer-events: none;\n}\n.trace { overflow: visible; }\n.trace .veil { fill: color-mix(in srgb, var(--plotwork-paper) 62%, transparent); }\n.trace .halo, .trace .stroke, .trace .ribbon, .trace .end, .trace .label, .trace .chosen {\n vector-effect: non-scaling-stroke;\n stroke-linecap: round;\n stroke-linejoin: round;\n}\n.trace .halo { fill: none; stroke: var(--plotwork-paper); stroke-width: 6; }\n.trace .stroke { fill: none; stroke: var(--plotwork-trace); stroke-width: 2.5; }\n.trace .ribbon { fill: color-mix(in srgb, var(--plotwork-trace) 30%, transparent); stroke: var(--plotwork-trace); stroke-width: 1.5; }\n.trace .end, .trace .label { fill: none; stroke: var(--plotwork-trace); stroke-width: 1.5; }\n.trace .chosen { fill: none; stroke: var(--plotwork-trace); stroke-width: 3; }\n.toolbar {\n position: absolute;\n inset: auto 12px 12px auto;\n display: flex;\n align-items: center;\n gap: 2px;\n padding: 4px;\n border: 1px solid var(--plotwork-border);\n border-radius: 999px;\n background: var(--plotwork-control);\n box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);\n}\n:host([controls='none']) .toolbar { display: none; }\n.toolbar button {\n display: inline-grid;\n place-items: center;\n inline-size: 32px;\n block-size: 32px;\n padding: 0;\n border: 0;\n border-radius: 999px;\n background: transparent;\n color: inherit;\n cursor: pointer;\n}\n.toolbar button:hover { background: color-mix(in srgb, var(--plotwork-ink) 10%, transparent); }\n.toolbar button:focus-visible { outline: 2px solid var(--plotwork-trace); outline-offset: 1px; }\n.toolbar svg path { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }\n.toolbar output { min-inline-size: 3.5em; font-size: 12px; font-variant-numeric: tabular-nums; text-align: center; }\n.notice, .hint {\n position: absolute;\n inset: 50% auto auto 50%;\n transform: translate(-50%, -50%);\n margin: 0;\n max-inline-size: min(36ch, 90%);\n text-align: center;\n font-size: 14px;\n}\n.hint {\n padding: 6px 12px;\n border-radius: 999px;\n background: rgb(23 23 22 / 0.86);\n color: #f4f4f1;\n pointer-events: none;\n opacity: 0;\n}\n.hint[data-shown] { opacity: 1; }\n.notice[hidden], .hint[hidden], .toolbar[hidden] { display: none; }\n.live {\n position: absolute;\n inline-size: 1px;\n block-size: 1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n@media (prefers-reduced-motion: no-preference) {\n .picture[data-glide], .trace[data-glide] { transition: transform 160ms ease-out, width 160ms ease-out, height 160ms ease-out; }\n .hint { transition: opacity 200ms ease-out; }\n}\n";
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The element's style sheet, inside its shadow root, so a page's CSS never reaches in and the element's never leaks out. A page
3
+ * themes it through the custom properties on `:host` (`--plotwork-paper`, `--plotwork-ink`, `--plotwork-trace`, `--plotwork-radius`,
4
+ * `--plotwork-font`, `--plotwork-height`) and styles the parts named with `part` (`stage`, `canvas`, `toolbar`, `button`, `notice`).
5
+ */
6
+ export const VIEWER_CSS = `
7
+ :host {
8
+ --plotwork-paper: #f4f4f1;
9
+ --plotwork-ink: #52514e;
10
+ --plotwork-trace: #0f766e;
11
+ --plotwork-control: #ffffff;
12
+ --plotwork-border: color-mix(in srgb, var(--plotwork-ink) 18%, transparent);
13
+ --plotwork-dot: color-mix(in srgb, var(--plotwork-ink) 22%, transparent);
14
+ --plotwork-radius: 10px;
15
+ --plotwork-font: system-ui, -apple-system, 'Segoe UI', sans-serif;
16
+ --plotwork-height: 480px;
17
+ display: block;
18
+ position: relative;
19
+ block-size: var(--plotwork-height);
20
+ min-block-size: 200px;
21
+ color-scheme: light dark;
22
+ font-family: var(--plotwork-font);
23
+ color: var(--plotwork-ink);
24
+ }
25
+ :host([hidden]) { display: none; }
26
+ :host([theme='light']) { color-scheme: light; }
27
+ :host([theme='dark']) { color-scheme: dark; }
28
+ :host([data-paper='dark']) {
29
+ --plotwork-paper: #171716;
30
+ --plotwork-ink: #c3c2b7;
31
+ --plotwork-trace: #2dd4bf;
32
+ --plotwork-control: #262624;
33
+ --plotwork-dot: color-mix(in srgb, var(--plotwork-ink) 16%, transparent);
34
+ }
35
+ .stage {
36
+ position: absolute;
37
+ inset: 0;
38
+ overflow: hidden;
39
+ border: 1px solid var(--plotwork-border);
40
+ border-radius: var(--plotwork-radius);
41
+ background: var(--plotwork-paper);
42
+ }
43
+ .canvas {
44
+ position: absolute;
45
+ inset: 0;
46
+ overflow: hidden;
47
+ touch-action: none;
48
+ cursor: grab;
49
+ background-image: radial-gradient(circle, var(--plotwork-dot) 0.9px, transparent 1.4px);
50
+ outline: none;
51
+ }
52
+ .canvas:focus-visible { box-shadow: inset 0 0 0 2px var(--plotwork-trace); border-radius: var(--plotwork-radius); }
53
+ .canvas[data-dragging] { cursor: grabbing; }
54
+ .canvas[data-drill] { cursor: pointer; }
55
+ .picture, .trace {
56
+ position: absolute;
57
+ inset: 0 auto auto 0;
58
+ display: block;
59
+ max-inline-size: none;
60
+ transform-origin: 0 0;
61
+ user-select: none;
62
+ -webkit-user-drag: none;
63
+ pointer-events: none;
64
+ }
65
+ .trace { overflow: visible; }
66
+ .trace .veil { fill: color-mix(in srgb, var(--plotwork-paper) 62%, transparent); }
67
+ .trace .halo, .trace .stroke, .trace .ribbon, .trace .end, .trace .label, .trace .chosen {
68
+ vector-effect: non-scaling-stroke;
69
+ stroke-linecap: round;
70
+ stroke-linejoin: round;
71
+ }
72
+ .trace .halo { fill: none; stroke: var(--plotwork-paper); stroke-width: 6; }
73
+ .trace .stroke { fill: none; stroke: var(--plotwork-trace); stroke-width: 2.5; }
74
+ .trace .ribbon { fill: color-mix(in srgb, var(--plotwork-trace) 30%, transparent); stroke: var(--plotwork-trace); stroke-width: 1.5; }
75
+ .trace .end, .trace .label { fill: none; stroke: var(--plotwork-trace); stroke-width: 1.5; }
76
+ .trace .chosen { fill: none; stroke: var(--plotwork-trace); stroke-width: 3; }
77
+ .toolbar {
78
+ position: absolute;
79
+ inset: auto 12px 12px auto;
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 2px;
83
+ padding: 4px;
84
+ border: 1px solid var(--plotwork-border);
85
+ border-radius: 999px;
86
+ background: var(--plotwork-control);
87
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
88
+ }
89
+ :host([controls='none']) .toolbar { display: none; }
90
+ .toolbar button {
91
+ display: inline-grid;
92
+ place-items: center;
93
+ inline-size: 32px;
94
+ block-size: 32px;
95
+ padding: 0;
96
+ border: 0;
97
+ border-radius: 999px;
98
+ background: transparent;
99
+ color: inherit;
100
+ cursor: pointer;
101
+ }
102
+ .toolbar button:hover { background: color-mix(in srgb, var(--plotwork-ink) 10%, transparent); }
103
+ .toolbar button:focus-visible { outline: 2px solid var(--plotwork-trace); outline-offset: 1px; }
104
+ .toolbar svg path { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
105
+ .toolbar output { min-inline-size: 3.5em; font-size: 12px; font-variant-numeric: tabular-nums; text-align: center; }
106
+ .notice, .hint {
107
+ position: absolute;
108
+ inset: 50% auto auto 50%;
109
+ transform: translate(-50%, -50%);
110
+ margin: 0;
111
+ max-inline-size: min(36ch, 90%);
112
+ text-align: center;
113
+ font-size: 14px;
114
+ }
115
+ .hint {
116
+ padding: 6px 12px;
117
+ border-radius: 999px;
118
+ background: rgb(23 23 22 / 0.86);
119
+ color: #f4f4f1;
120
+ pointer-events: none;
121
+ opacity: 0;
122
+ }
123
+ .hint[data-shown] { opacity: 1; }
124
+ .notice[hidden], .hint[hidden], .toolbar[hidden] { display: none; }
125
+ .live {
126
+ position: absolute;
127
+ inline-size: 1px;
128
+ block-size: 1px;
129
+ overflow: hidden;
130
+ clip-path: inset(50%);
131
+ white-space: nowrap;
132
+ }
133
+ @media (prefers-reduced-motion: no-preference) {
134
+ .picture[data-glide], .trace[data-glide] { transition: transform 160ms ease-out, width 160ms ease-out, height 160ms ease-out; }
135
+ .hint { transition: opacity 200ms ease-out; }
136
+ }
137
+ `;
@@ -0,0 +1,9 @@
1
+ import type { View } from '../core/canvas-view.js';
2
+ import type { Trace, TraceMap } from '../core/picture-map.js';
3
+ /** The overlay for one trace. `place` sizes and moves it with the picture. */
4
+ export declare function traceLayer(map: TraceMap, trace: Trace): SVGSVGElement;
5
+ /** Sizes and moves an element drawn in the picture's units (the `img` or the overlay) to a view. */
6
+ export declare function place(element: HTMLElement | SVGElement, size: {
7
+ width: number;
8
+ height: number;
9
+ }, view: View): void;
@@ -0,0 +1,45 @@
1
+ import { svg } from './dom.js';
2
+ /**
3
+ * The traced box's lines, drawn by the element over the picture, never inside it. The picture stays an `img`, which runs no
4
+ * script; this is an SVG of the element's own, in the picture's viewBox, sized and moved exactly as the `img` is. It holds only
5
+ * numbers and the path data `readTraceMap` checked. A veil dims everything but the traced box, the boxes at the other ends and the
6
+ * lines' labels; the lines are drawn again over the veil, stopping at each label; and the boxes are outlined. The same as the
7
+ * portal's `TraceOverlay`.
8
+ */
9
+ /** How far an outline sits outside the box it marks, in the picture's units. */
10
+ const OUTSET = 3;
11
+ const outset = (box, by) => ({ x: box.x - by, y: box.y - by, width: box.width + 2 * by, height: box.height + 2 * by });
12
+ const rect = (box, extra) => svg('rect', { x: box.x, y: box.y, width: box.width, height: box.height, ...extra });
13
+ let masks = 0;
14
+ /** The overlay for one trace. `place` sizes and moves it with the picture. */
15
+ export function traceLayer(map, trace) {
16
+ masks += 1;
17
+ const maskId = `plotwork-trace-${masks}`;
18
+ const { viewBox } = map;
19
+ const others = trace.lines.map((line) => line.other).filter((node, index, all) => node.id !== trace.node.id && all.findIndex((seen) => seen.id === node.id) === index);
20
+ const labels = trace.lines.map((line) => line.line.label).filter((label) => label !== null);
21
+ const chosen = outset(trace.node.box, OUTSET);
22
+ const holes = [chosen, ...others.map((node) => outset(node.box, OUTSET)), ...labels.map((label) => outset(label, 2))];
23
+ const whole = { x: viewBox.x, y: viewBox.y, width: viewBox.width, height: viewBox.height };
24
+ const unitsOf = { maskUnits: 'userSpaceOnUse', ...whole };
25
+ const lines = svg('g', { mask: `url(#${maskId}-lines)` });
26
+ for (const { line } of trace.lines) {
27
+ const group = svg('g', { 'data-line': line.id });
28
+ for (const path of line.paths) {
29
+ const placed = svg('g', path.dx === 0 && path.dy === 0 ? {} : { transform: `translate(${path.dx} ${path.dy})` });
30
+ placed.append(svg('path', { class: 'halo', d: path.d }), svg('path', { class: path.filled ? 'ribbon' : 'stroke', d: path.d }));
31
+ group.append(placed);
32
+ }
33
+ lines.append(group);
34
+ }
35
+ return svg('svg', { class: 'trace', 'aria-hidden': 'true', focusable: 'false', 'data-trace': trace.node.id, viewBox: `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}` }, svg('defs', {}, svg('mask', { id: maskId, ...unitsOf }, rect(whole, { fill: '#fff' }), ...holes.map((hole) => rect(hole, { rx: 6, fill: '#000' }))), svg('mask', { id: `${maskId}-lines`, ...unitsOf }, rect(whole, { fill: '#fff' }), ...labels.map((label) => rect(label, { rx: label.height / 2, fill: '#000' })))), rect(whole, { class: 'veil', mask: `url(#${maskId})` }), lines, ...labels.map((label) => {
36
+ const box = outset(label, 2);
37
+ return rect(box, { class: 'label', rx: box.height / 2 });
38
+ }), ...others.map((node) => rect(outset(node.box, OUTSET), { class: 'end', 'data-end': node.id, rx: node.radius + OUTSET })), rect(chosen, { class: 'chosen', rx: trace.node.radius + OUTSET }));
39
+ }
40
+ /** Sizes and moves an element drawn in the picture's units (the `img` or the overlay) to a view. */
41
+ export function place(element, size, view) {
42
+ element.style.width = `${size.width * view.scale}px`;
43
+ element.style.height = `${size.height * view.scale}px`;
44
+ element.style.transform = `translate(${view.x}px, ${view.y}px)`;
45
+ }
@@ -0,0 +1,7 @@
1
+ export { definePlotworkDiagram, TAG_NAME } from './element/define.js';
2
+ export type { PlotworkDiagramElement } from './element/plotwork-diagram.js';
3
+ export { DEFAULT_LABELS } from './element/copy.js';
4
+ export type { ViewerLabels } from './element/copy.js';
5
+ export type { ChangeDetail, DrillDetail, ErrorDetail, LoadDetail, PlotworkEventMap, TraceDetail } from './element/events.js';
6
+ export { fingerprintOf, isFingerprint, readEmbeddedSource, readTraceMap } from './core/index.js';
7
+ export type { LoadFailure, SourceReading, ThemeChoice, TraceMap } from './core/index.js';