@openbrf/display 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 (69) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +126 -2
  4. package/dist/contract.d.ts +244 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +54 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/fetch-loop.d.ts +87 -0
  9. package/dist/fetch-loop.d.ts.map +1 -0
  10. package/dist/fetch-loop.js +351 -0
  11. package/dist/fetch-loop.js.map +1 -0
  12. package/dist/fit.d.ts +44 -0
  13. package/dist/fit.d.ts.map +1 -0
  14. package/dist/fit.js +257 -0
  15. package/dist/fit.js.map +1 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/parse.d.ts +39 -0
  21. package/dist/parse.d.ts.map +1 -0
  22. package/dist/parse.js +494 -0
  23. package/dist/parse.js.map +1 -0
  24. package/dist/renderer.d.ts +85 -0
  25. package/dist/renderer.d.ts.map +1 -0
  26. package/dist/renderer.js +476 -0
  27. package/dist/renderer.js.map +1 -0
  28. package/dist/rotation.d.ts +129 -0
  29. package/dist/rotation.d.ts.map +1 -0
  30. package/dist/rotation.js +335 -0
  31. package/dist/rotation.js.map +1 -0
  32. package/dist/slides.d.ts +41 -0
  33. package/dist/slides.d.ts.map +1 -0
  34. package/dist/slides.js +341 -0
  35. package/dist/slides.js.map +1 -0
  36. package/dist/styles.d.ts +10 -0
  37. package/dist/styles.d.ts.map +1 -0
  38. package/dist/styles.js +107 -0
  39. package/dist/styles.js.map +1 -0
  40. package/dist/text.d.ts +16 -0
  41. package/dist/text.d.ts.map +1 -0
  42. package/dist/text.js +62 -0
  43. package/dist/text.js.map +1 -0
  44. package/dist/time.d.ts +39 -0
  45. package/dist/time.d.ts.map +1 -0
  46. package/dist/time.js +94 -0
  47. package/dist/time.js.map +1 -0
  48. package/dist/translate.d.ts +18 -0
  49. package/dist/translate.d.ts.map +1 -0
  50. package/dist/translate.js +35 -0
  51. package/dist/translate.js.map +1 -0
  52. package/dist/words.d.ts +41 -0
  53. package/dist/words.d.ts.map +1 -0
  54. package/dist/words.js +73 -0
  55. package/dist/words.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/contract.ts +279 -0
  58. package/src/fetch-loop.ts +472 -0
  59. package/src/fit.ts +348 -0
  60. package/src/index.ts +77 -0
  61. package/src/parse.ts +605 -0
  62. package/src/renderer.ts +708 -0
  63. package/src/rotation.ts +515 -0
  64. package/src/slides.ts +595 -0
  65. package/src/styles.ts +113 -0
  66. package/src/text.ts +65 -0
  67. package/src/time.ts +147 -0
  68. package/src/translate.ts +56 -0
  69. package/src/words.ts +91 -0
package/src/fit.ts ADDED
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Fitting a slide to the canvas.
3
+ *
4
+ * The contract lets a slide hold more than one canvas can show at a size read
5
+ * from across a stairwell: eight events, twelve resources, an agenda of forty
6
+ * items, sixty table rows, a hundred and twenty doors, two thousand characters
7
+ * of news in as many paragraphs and lines as it likes. Nothing a slide holds is
8
+ * cut off; a slide the canvas cannot hold is shown a page at a time:
9
+ *
10
+ * 1. A slide's list is shown as many of its items as the canvas holds whole.
11
+ * 2. A page still too tall, such as one long event or a news item, is set
12
+ * smaller, down to `MIN_SCALE` of its size.
13
+ * 3. A page too tall even then continues on the next page, its text broken
14
+ * between two words, or a word longer than a page holds between two of its
15
+ * characters.
16
+ *
17
+ * It measures the slide in the page, so it does nothing where nothing is laid
18
+ * out: before the element has a size, and under a DOM without layout.
19
+ */
20
+
21
+ /** The attribute on a slide's list that may be shown a page at a time. */
22
+ export const PAGED_LIST_ATTRIBUTE = "data-obrf-display-paged";
23
+
24
+ /**
25
+ * The attribute on an element of a slide whose text is all on other pages of
26
+ * it. The stylesheet takes it out of the page, except a table's cell, which
27
+ * stays empty to keep its row's columns.
28
+ */
29
+ export const ELSEWHERE_ATTRIBUTE = "data-obrf-display-elsewhere";
30
+
31
+ /** The smallest a slide is set, as a share of its size. */
32
+ const MIN_SCALE = 0.6;
33
+
34
+ const SCALE_STEP = 0.05;
35
+
36
+ /** Which page of the slide is shown, of how many, and whether it fits. */
37
+ export interface SlideFit {
38
+ page: number;
39
+ pages: number;
40
+ /**
41
+ * False when the page still holds more than it shows: a single character
42
+ * too wide or too tall for the canvas even at `MIN_SCALE`.
43
+ */
44
+ fits: boolean;
45
+ }
46
+
47
+ /** One page of a slide. */
48
+ interface Page {
49
+ /** The items of the slide's list on it, from and up to. */
50
+ from: number;
51
+ to: number;
52
+ /** The unit it is set in, or null for the canvas's own. */
53
+ unit: string | null;
54
+ /** The part of its text on it, from and up to, or null for all of it. */
55
+ text: readonly [number, number] | null;
56
+ }
57
+
58
+ /**
59
+ * Fits a slide that is on the stage, showing page `page` of it (the last page
60
+ * when it has fewer). `unit` is the canvas's unit in pixels, a hundredth of
61
+ * its shorter side, or 0 when it has no size.
62
+ */
63
+ export function fitSlide(
64
+ slide: HTMLElement,
65
+ unit: number,
66
+ page: number,
67
+ ): SlideFit {
68
+ const list = slide.querySelector(`[${PAGED_LIST_ATTRIBUTE}]`);
69
+ const items =
70
+ list === null
71
+ ? []
72
+ : Array.from(list.children as HTMLCollectionOf<HTMLElement>);
73
+ restore(slide, items);
74
+ if (unit <= 0 || !overflows(slide)) {
75
+ return { page: 0, pages: 1, fits: true };
76
+ }
77
+ const pages: Page[] = [];
78
+ for (const [from, to] of itemPages(slide, items)) {
79
+ restore(slide, items);
80
+ show(items, from, to);
81
+ const scaled = shrink(slide, unit);
82
+ if (!overflows(slide)) {
83
+ pages.push({ from, to, unit: scaled, text: null });
84
+ continue;
85
+ }
86
+ const parts = textPages(slide, textFlow(slide));
87
+ for (const text of parts.length > 0 ? parts : [null]) {
88
+ pages.push({ from, to, unit: scaled, text });
89
+ }
90
+ }
91
+ const shown = Math.min(Math.max(0, page), pages.length - 1);
92
+ const chosen = pages[shown] ?? {
93
+ from: 0,
94
+ to: items.length,
95
+ unit: null,
96
+ text: null,
97
+ };
98
+ restore(slide, items);
99
+ show(items, chosen.from, chosen.to);
100
+ if (chosen.unit !== null) {
101
+ slide.style.setProperty("--obrf-display-u", chosen.unit);
102
+ }
103
+ if (chosen.text !== null) {
104
+ textFlow(slide).show(...chosen.text);
105
+ }
106
+ return { page: shown, pages: pages.length, fits: !overflows(slide) };
107
+ }
108
+
109
+ /** Shows the items from `from` up to `to`, and hides the rest. */
110
+ function show(items: readonly HTMLElement[], from: number, to: number): void {
111
+ items.forEach((item, index) => {
112
+ item.hidden = index < from || index >= to;
113
+ });
114
+ }
115
+
116
+ /** The ranges of the list's items a page each holds whole: all of it when it has one item or none. */
117
+ function itemPages(
118
+ slide: HTMLElement,
119
+ items: readonly HTMLElement[],
120
+ ): [number, number][] {
121
+ if (items.length <= 1) {
122
+ return [[0, items.length]];
123
+ }
124
+ const pages: [number, number][] = [];
125
+ for (let start = 0; start < items.length;) {
126
+ const count = fitting(slide, items, start);
127
+ pages.push([start, start + count]);
128
+ start += count;
129
+ }
130
+ return pages;
131
+ }
132
+
133
+ /** How many items from `start` the slide holds whole: at least one. */
134
+ function fitting(
135
+ slide: HTMLElement,
136
+ items: readonly HTMLElement[],
137
+ start: number,
138
+ ): number {
139
+ let low = 1;
140
+ let high = items.length - start;
141
+ while (low < high) {
142
+ const count = Math.ceil((low + high) / 2);
143
+ show(items, start, start + count);
144
+ if (overflows(slide)) {
145
+ high = count - 1;
146
+ } else {
147
+ low = count;
148
+ }
149
+ }
150
+ return low;
151
+ }
152
+
153
+ /** Sets the slide smaller until it fits or reaches `MIN_SCALE`, and hands back the unit it set. */
154
+ function shrink(slide: HTMLElement, unit: number): string | null {
155
+ let set: string | null = null;
156
+ for (
157
+ let scale = 1 - SCALE_STEP;
158
+ overflows(slide) && scale >= MIN_SCALE - SCALE_STEP / 2;
159
+ scale -= SCALE_STEP
160
+ ) {
161
+ set = `${unit * scale}px`;
162
+ slide.style.setProperty("--obrf-display-u", set);
163
+ }
164
+ return set;
165
+ }
166
+
167
+ /**
168
+ * The pages of text the slide holds, each at least one character. A page ends
169
+ * between two words. A word longer than a page holds starts on the page it
170
+ * comes to, is broken between two of its characters, and goes on on the next.
171
+ */
172
+ function textPages(
173
+ slide: HTMLElement,
174
+ flow: TextFlow,
175
+ ): (readonly [number, number])[] {
176
+ const pages: [number, number][] = [];
177
+ for (let start = 0; start < flow.length;) {
178
+ const words = flow.wordEnds.filter((wordEnd) => wordEnd > start);
179
+ let end = furthest(slide, flow, start, words);
180
+ const next = words.find((wordEnd) => wordEnd > end);
181
+ flow.show(start, end);
182
+ if (overflows(slide)) {
183
+ // The page is one word, and the word longer than it.
184
+ end = furthest(slide, flow, start, flow.characterEnds(start, end));
185
+ } else if (next !== undefined) {
186
+ // The next word would be longer than a page even on a page of its own.
187
+ flow.show(end, next);
188
+ if (overflows(slide)) {
189
+ end = furthest(slide, flow, start, [
190
+ end,
191
+ ...flow.characterEnds(end, next),
192
+ ]);
193
+ }
194
+ }
195
+ pages.push([start, end]);
196
+ start = end;
197
+ }
198
+ return pages;
199
+ }
200
+
201
+ /** The furthest of `ends` the slide holds its text up to from `start`: at least the first. */
202
+ function furthest(
203
+ slide: HTMLElement,
204
+ flow: TextFlow,
205
+ start: number,
206
+ ends: readonly number[],
207
+ ): number {
208
+ let low = 0;
209
+ let high = ends.length - 1;
210
+ while (low < high) {
211
+ const middle = Math.ceil((low + high) / 2);
212
+ flow.show(start, ends[middle] ?? flow.length);
213
+ if (overflows(slide)) {
214
+ high = middle - 1;
215
+ } else {
216
+ low = middle;
217
+ }
218
+ }
219
+ return ends[low] ?? flow.length;
220
+ }
221
+
222
+ /**
223
+ * The text of the slide's shown items, its text nodes one after another, that
224
+ * can be shown a part at a time. A part is given by where it starts and ends
225
+ * in the whole text.
226
+ */
227
+ interface TextFlow {
228
+ readonly length: number;
229
+ /** Where each word ends, after the space or line break that follows it. */
230
+ readonly wordEnds: readonly number[];
231
+ /** Where each character from `from` up to `to` ends, a character being what a reader sees as one. */
232
+ characterEnds(from: number, to: number): number[];
233
+ /** Shows the text from `from` up to `to`, and takes what holds none of it out of the page. */
234
+ show(from: number, to: number): void;
235
+ }
236
+
237
+ /** Each text node's own text, while a page shows part of it. */
238
+ const originals = new WeakMap<Text, string>();
239
+
240
+ function textNodes(slide: HTMLElement): Text[] {
241
+ const nodes: Text[] = [];
242
+ const walker = slide.ownerDocument.createTreeWalker(
243
+ slide,
244
+ NodeFilter.SHOW_TEXT,
245
+ );
246
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
247
+ nodes.push(node as Text);
248
+ }
249
+ return nodes;
250
+ }
251
+
252
+ /** Puts every text back, shows every element and item, and sets the slide at the canvas's unit. */
253
+ function restore(slide: HTMLElement, items: readonly HTMLElement[]): void {
254
+ slide.style.removeProperty("--obrf-display-u");
255
+ for (const node of textNodes(slide)) {
256
+ const text = originals.get(node);
257
+ if (text !== undefined && node.data !== text) {
258
+ node.data = text;
259
+ }
260
+ }
261
+ for (const node of Array.from(
262
+ slide.querySelectorAll(`[${ELSEWHERE_ATTRIBUTE}]`),
263
+ )) {
264
+ node.removeAttribute(ELSEWHERE_ATTRIBUTE);
265
+ }
266
+ show(items, 0, items.length);
267
+ }
268
+
269
+ function textFlow(slide: HTMLElement): TextFlow {
270
+ const texts: { node: Text; text: string; first: number }[] = [];
271
+ const wordEnds: number[] = [];
272
+ let whole = "";
273
+ for (const node of textNodes(slide)) {
274
+ if (node.parentElement?.closest("[hidden]") != null) {
275
+ continue;
276
+ }
277
+ const text = originals.get(node) ?? node.data;
278
+ originals.set(node, text);
279
+ // A word ends where the next one starts, so that a page ends after the
280
+ // space or line break that follows its last word.
281
+ const ends = Array.from(
282
+ text.matchAll(/\S+\s*/g),
283
+ (match) => whole.length + match.index + match[0].length,
284
+ );
285
+ if (ends.length > 0) {
286
+ texts.push({ node, text, first: whole.length });
287
+ wordEnds.push(...ends);
288
+ whole += text;
289
+ }
290
+ }
291
+ return {
292
+ length: whole.length,
293
+ wordEnds,
294
+ characterEnds(from, to) {
295
+ const part = whole.slice(from, to);
296
+ if (typeof Intl.Segmenter === "function") {
297
+ return Array.from(
298
+ new Intl.Segmenter().segment(part),
299
+ ({ index, segment }) => from + index + segment.length,
300
+ );
301
+ }
302
+ // A browser without it breaks between two code points.
303
+ const ends: number[] = [];
304
+ let end = from;
305
+ for (const character of part) {
306
+ end += character.length;
307
+ ends.push(end);
308
+ }
309
+ return ends;
310
+ },
311
+ show(from, to) {
312
+ const holding = new Set<Element>();
313
+ const empty = new Set<Element>();
314
+ for (const { node, text, first } of texts) {
315
+ const start = Math.max(from - first, 0);
316
+ const end = Math.min(to - first, text.length);
317
+ const shown = start >= end ? "" : text.slice(start, end);
318
+ node.data = end < text.length ? shown.trimEnd() : shown;
319
+ for (
320
+ let element = node.parentElement;
321
+ element !== null && element !== slide;
322
+ element = element.parentElement
323
+ ) {
324
+ (shown === "" ? empty : holding).add(element);
325
+ }
326
+ }
327
+ for (const element of new Set([...empty, ...holding])) {
328
+ element.toggleAttribute(ELSEWHERE_ATTRIBUTE, !holding.has(element));
329
+ }
330
+ },
331
+ };
332
+ }
333
+
334
+ /**
335
+ * Whether the slide, or a box in it that clips its own text, holds more than
336
+ * it shows.
337
+ */
338
+ function overflows(slide: HTMLElement): boolean {
339
+ const boxes = [
340
+ slide,
341
+ ...Array.from(slide.querySelectorAll<HTMLElement>(".obrf-display-copy")),
342
+ ];
343
+ return boxes.some(
344
+ (box) =>
345
+ box.scrollHeight > box.clientHeight + 1 ||
346
+ box.scrollWidth > box.clientWidth + 1,
347
+ );
348
+ }
package/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * @openbrf/display: what every screen showing an association's content is
3
+ * built from (ADR 0050). The core's display mode and the signage module's
4
+ * player both use all four parts:
5
+ *
6
+ * 1. the screen content contract and its parser,
7
+ * 2. the rotation builder,
8
+ * 3. the renderer,
9
+ * 4. the fetch loop.
10
+ */
11
+ export {
12
+ SCREEN_CONTENT_LIMITS,
13
+ SCREEN_CONTENT_VERSION,
14
+ SLIDE_ID_PATTERN,
15
+ } from "./contract.ts";
16
+ export type {
17
+ AddonBody,
18
+ AddonDoor,
19
+ AddonDoorsBody,
20
+ AddonTableBody,
21
+ AddonTextBody,
22
+ AgendaItem,
23
+ AvailabilityBody,
24
+ AvailabilityResource,
25
+ BusyInterval,
26
+ EventsBody,
27
+ EventsOccurrence,
28
+ Instant,
29
+ IsoWeekday,
30
+ LocalTime,
31
+ MeetingBody,
32
+ NewsBody,
33
+ PictureBody,
34
+ PictureRef,
35
+ ScreenContent,
36
+ ScreenLayout,
37
+ ScreenLocale,
38
+ ScreenSchedule,
39
+ ScreenScheduleWindow,
40
+ Slide,
41
+ SlideBody,
42
+ SlideSource,
43
+ } from "./contract.ts";
44
+ export { parseScreenContent, ScreenContentError } from "./parse.ts";
45
+ export type { ScreenContentErrorReason } from "./parse.ts";
46
+ export { shortenText, shortenTexts } from "./text.ts";
47
+ export {
48
+ availabilityWindow,
49
+ buildRotation,
50
+ EVENTS_DAYS,
51
+ NEWS_SLIDES,
52
+ } from "./rotation.ts";
53
+ export type {
54
+ BookableResourceInput,
55
+ BusyIntervalInput,
56
+ MeetingNoticeInput,
57
+ NewsItemInput,
58
+ OccurrenceInput,
59
+ PictureFileInput,
60
+ RotationInput,
61
+ } from "./rotation.ts";
62
+ export { mountRenderer } from "./renderer.ts";
63
+ export type {
64
+ DisplayMessage,
65
+ DisplayRenderer,
66
+ DisplayStatus,
67
+ RendererOptions,
68
+ } from "./renderer.ts";
69
+ export { DISPLAY_STYLESHEET } from "./styles.ts";
70
+ export { startFetchLoop, STALE_CONTENT_LIMIT_MS } from "./fetch-loop.ts";
71
+ export type {
72
+ CachedScreenContent,
73
+ FetchedStatus,
74
+ FetchLoop,
75
+ FetchLoopOptions,
76
+ ScreenContentCache,
77
+ } from "./fetch-loop.ts";