@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/slides.ts ADDED
@@ -0,0 +1,595 @@
1
+ /**
2
+ * One slide's body as DOM.
3
+ *
4
+ * Every text from the document is set with `textContent` and nothing is ever
5
+ * parsed as markup, because the contract makes every string plain text: a
6
+ * news title that reads `<img src=x onerror=…>` shows those characters.
7
+ */
8
+ import type {
9
+ AddonDoorsBody,
10
+ AddonTableBody,
11
+ AddonTextBody,
12
+ AvailabilityBody,
13
+ EventsBody,
14
+ MeetingBody,
15
+ NewsBody,
16
+ PictureBody,
17
+ PictureRef,
18
+ ScreenLocale,
19
+ Slide,
20
+ } from "./contract.ts";
21
+ import { PAGED_LIST_ATTRIBUTE } from "./fit.ts";
22
+ import { translate } from "./translate.ts";
23
+ import { zonedDayStartAfter, zonedParts } from "./time.ts";
24
+
25
+ /** What a body needs besides itself. */
26
+ export interface SlideContext {
27
+ readonly document: Document;
28
+ readonly locale: ScreenLocale;
29
+ readonly timeZone: string;
30
+ readonly now: number;
31
+ /** The picture's URL when the caller handed one, otherwise null. */
32
+ pictureUrl(ref: PictureRef): string | null;
33
+ /** The browser could not show a picture it was given. */
34
+ pictureFailed(ref: PictureRef): void;
35
+ }
36
+
37
+ /** The language tag a locale is formatted in: 24-hour, day before month. */
38
+ export function localeTag(locale: ScreenLocale): string {
39
+ return locale === "sv" ? "sv-SE" : "en-GB";
40
+ }
41
+
42
+ const formatters = new Map<string, Intl.DateTimeFormat>();
43
+
44
+ /** Formats an instant in the screen's locale and time zone. */
45
+ export function formatTime(
46
+ time: number,
47
+ locale: ScreenLocale,
48
+ timeZone: string,
49
+ style: "time" | "date" | "dayShort" | "weekdayDate" | "weekdayDateTime",
50
+ ): string {
51
+ const key = `${locale}\u0000${timeZone}\u0000${style}`;
52
+ let formatter = formatters.get(key);
53
+ if (formatter === undefined) {
54
+ formatter = new Intl.DateTimeFormat(localeTag(locale), {
55
+ timeZone,
56
+ ...FORMATS[style],
57
+ });
58
+ formatters.set(key, formatter);
59
+ }
60
+ return formatter.format(time);
61
+ }
62
+
63
+ const TIME: Intl.DateTimeFormatOptions = {
64
+ hour: "2-digit",
65
+ minute: "2-digit",
66
+ hourCycle: "h23",
67
+ };
68
+
69
+ const FORMATS: Record<string, Intl.DateTimeFormatOptions> = {
70
+ time: TIME,
71
+ date: { day: "numeric", month: "long" },
72
+ dayShort: { weekday: "short", day: "numeric", month: "short" },
73
+ weekdayDate: { weekday: "long", day: "numeric", month: "long" },
74
+ weekdayDateTime: {
75
+ weekday: "long",
76
+ day: "numeric",
77
+ month: "long",
78
+ ...TIME,
79
+ },
80
+ };
81
+
82
+ /** The text with its first letter in capitals, as a heading starts. */
83
+ export function capitalised(text: string): string {
84
+ return text.charAt(0).toLocaleUpperCase() + text.slice(1);
85
+ }
86
+
87
+ /**
88
+ * Empties `parent` and puts `nodes` in it. What `Element.replaceChildren`
89
+ * does, written with calls Chromium 85 has: that method came in 86.
90
+ */
91
+ export function replaceContent(
92
+ parent: Element,
93
+ ...nodes: (Node | string)[]
94
+ ): void {
95
+ parent.textContent = "";
96
+ parent.append(...nodes);
97
+ }
98
+
99
+ /** A new element, with a class and its text. */
100
+ export function element<K extends keyof HTMLElementTagNameMap>(
101
+ document: Document,
102
+ tag: K,
103
+ className?: string,
104
+ text?: string,
105
+ ): HTMLElementTagNameMap[K] {
106
+ const node = document.createElement(tag);
107
+ if (className !== undefined) {
108
+ node.className = className;
109
+ }
110
+ if (text !== undefined) {
111
+ node.textContent = text;
112
+ }
113
+ return node;
114
+ }
115
+
116
+ /** A list the renderer may show a page at a time, when the canvas cannot hold all of it. */
117
+ function pagedList<K extends "ol" | "ul" | "tbody">(
118
+ document: Document,
119
+ tag: K,
120
+ className?: string,
121
+ ): HTMLElementTagNameMap[K] {
122
+ const list = element(document, tag, className);
123
+ list.setAttribute(PAGED_LIST_ATTRIBUTE, "");
124
+ return list;
125
+ }
126
+
127
+ /** The slide's body, or null when it cannot be shown now (a picture slide without its picture). */
128
+ export function renderSlide(
129
+ slide: Slide,
130
+ context: SlideContext,
131
+ ): HTMLElement | null {
132
+ const { body } = slide;
133
+ switch (body.kind) {
134
+ case "news":
135
+ return news(body, context);
136
+ case "events":
137
+ return events(body, context);
138
+ case "meeting":
139
+ return meeting(body, context);
140
+ case "availability":
141
+ return availability(body, context);
142
+ case "picture":
143
+ return picture(body, context);
144
+ case "text":
145
+ return text(body, context);
146
+ case "table":
147
+ return table(body, context);
148
+ case "doors":
149
+ return doors(body, context);
150
+ }
151
+ }
152
+
153
+ function container(context: SlideContext, kind: string): HTMLElement {
154
+ return element(
155
+ context.document,
156
+ "section",
157
+ `obrf-display-slide obrf-display-${kind}`,
158
+ );
159
+ }
160
+
161
+ function image(ref: PictureRef, url: string, context: SlideContext) {
162
+ const img = element(context.document, "img", "obrf-display-picture");
163
+ img.alt = ref.alt;
164
+ img.decoding = "async";
165
+ img.addEventListener("error", () => context.pictureFailed(ref), {
166
+ once: true,
167
+ });
168
+ img.src = url;
169
+ return img;
170
+ }
171
+
172
+ function news(body: NewsBody, context: SlideContext): HTMLElement {
173
+ const { document } = context;
174
+ const section = container(context, "news");
175
+ const copy = element(document, "div", "obrf-display-copy");
176
+ const published = Date.parse(body.publishedAt);
177
+ copy.append(
178
+ element(
179
+ document,
180
+ "p",
181
+ "obrf-display-date",
182
+ formatTime(published, context.locale, context.timeZone, "date"),
183
+ ),
184
+ element(document, "h2", "obrf-display-heading", body.title),
185
+ ...body.paragraphs.map((paragraph) =>
186
+ element(document, "p", "obrf-display-text", paragraph),
187
+ ),
188
+ );
189
+ section.append(copy);
190
+ const url = body.picture === null ? null : context.pictureUrl(body.picture);
191
+ if (body.picture !== null && url !== null) {
192
+ section.classList.add("obrf-display-news--picture");
193
+ section.append(image(body.picture, url, context));
194
+ }
195
+ return section;
196
+ }
197
+
198
+ function timeRange(start: number, end: number, context: SlideContext): string {
199
+ const { locale, timeZone } = context;
200
+ const from = formatTime(start, locale, timeZone, "time");
201
+ const sameDay =
202
+ zonedDayStartAfter(start, 0, timeZone) ===
203
+ zonedDayStartAfter(end, 0, timeZone);
204
+ const to = sameDay
205
+ ? formatTime(end, locale, timeZone, "time")
206
+ : `${formatTime(end, locale, timeZone, "dayShort")} ${formatTime(end, locale, timeZone, "time")}`;
207
+ return translate(locale, "timeRange", { from, to });
208
+ }
209
+
210
+ function events(body: EventsBody, context: SlideContext): HTMLElement {
211
+ const { document, locale, timeZone } = context;
212
+ const section = container(context, "events");
213
+ section.append(
214
+ element(
215
+ document,
216
+ "h2",
217
+ "obrf-display-heading",
218
+ translate(locale, "comingEvents"),
219
+ ),
220
+ );
221
+ const list = pagedList(document, "ol", "obrf-display-list");
222
+ for (const occurrence of body.occurrences) {
223
+ const start = Date.parse(occurrence.startsAt);
224
+ const end = Date.parse(occurrence.endsAt);
225
+ const item = element(document, "li", "obrf-display-event");
226
+ const when = element(document, "div", "obrf-display-when");
227
+ when.append(
228
+ element(
229
+ document,
230
+ "span",
231
+ undefined,
232
+ capitalised(formatTime(start, locale, timeZone, "dayShort")),
233
+ ),
234
+ element(document, "span", undefined, timeRange(start, end, context)),
235
+ );
236
+ const what = element(document, "div", "obrf-display-what");
237
+ what.append(element(document, "p", "obrf-display-title", occurrence.title));
238
+ if (occurrence.location !== null) {
239
+ what.append(
240
+ element(
241
+ document,
242
+ "p",
243
+ "obrf-display-text obrf-display-muted",
244
+ occurrence.location,
245
+ ),
246
+ );
247
+ }
248
+ if (occurrence.description !== null) {
249
+ what.append(
250
+ element(
251
+ document,
252
+ "p",
253
+ "obrf-display-description",
254
+ occurrence.description,
255
+ ),
256
+ );
257
+ }
258
+ if (occurrence.calledOff) {
259
+ item.classList.add("obrf-display-event--called-off");
260
+ what.append(
261
+ element(
262
+ document,
263
+ "span",
264
+ "obrf-display-badge",
265
+ translate(locale, "calledOff"),
266
+ ),
267
+ );
268
+ } else if (occurrence.placesLeft !== null) {
269
+ what.append(
270
+ element(
271
+ document,
272
+ "p",
273
+ "obrf-display-text",
274
+ occurrence.placesLeft === 0
275
+ ? translate(locale, "fullyBooked")
276
+ : translate(locale, "placesLeft", { count: occurrence.placesLeft }),
277
+ ),
278
+ );
279
+ }
280
+ item.append(when, what);
281
+ list.append(item);
282
+ }
283
+ section.append(list);
284
+ return section;
285
+ }
286
+
287
+ function meeting(body: MeetingBody, context: SlideContext): HTMLElement {
288
+ const { document, locale, timeZone } = context;
289
+ const section = container(context, "meeting");
290
+ section.append(
291
+ element(
292
+ document,
293
+ "h2",
294
+ "obrf-display-heading",
295
+ translate(
296
+ locale,
297
+ body.meetingKind === "extraordinary"
298
+ ? "extraordinaryMeeting"
299
+ : "ordinaryMeeting",
300
+ ),
301
+ ),
302
+ );
303
+ const facts = element(document, "dl", "obrf-display-facts");
304
+ const fact = (term: string, value: string, className?: string) => {
305
+ facts.append(
306
+ element(document, "dt", undefined, term),
307
+ element(document, "dd", className, value),
308
+ );
309
+ };
310
+ fact(
311
+ translate(locale, "meetingWhen"),
312
+ capitalised(
313
+ formatTime(
314
+ Date.parse(body.startsAt),
315
+ locale,
316
+ timeZone,
317
+ "weekdayDateTime",
318
+ ),
319
+ ),
320
+ "obrf-display-data",
321
+ );
322
+ fact(translate(locale, "meetingWhere"), body.place);
323
+ if (body.digitalParticipation !== null) {
324
+ fact(translate(locale, "digitalParticipation"), body.digitalParticipation);
325
+ }
326
+ section.append(facts);
327
+ if (body.agenda.length > 0) {
328
+ const agenda = pagedList(document, "ol", "obrf-display-agenda");
329
+ for (const item of body.agenda) {
330
+ const entry = element(document, "li");
331
+ entry.append(
332
+ element(
333
+ document,
334
+ "span",
335
+ "obrf-display-agenda-position",
336
+ `${item.position}.`,
337
+ ),
338
+ element(document, "span", undefined, item.title),
339
+ );
340
+ agenda.append(entry);
341
+ }
342
+ section.append(
343
+ element(
344
+ document,
345
+ "h3",
346
+ "obrf-display-subheading",
347
+ translate(locale, "agenda"),
348
+ ),
349
+ agenda,
350
+ );
351
+ }
352
+ return section;
353
+ }
354
+
355
+ function availability(
356
+ body: AvailabilityBody,
357
+ context: SlideContext,
358
+ ): HTMLElement {
359
+ const { document, locale, timeZone, now } = context;
360
+ const section = container(context, "availability");
361
+ section.append(
362
+ element(
363
+ document,
364
+ "h2",
365
+ "obrf-display-heading",
366
+ translate(locale, "freeAndBusy"),
367
+ ),
368
+ );
369
+ const documentFrom = Date.parse(body.from);
370
+ const to = Date.parse(body.to);
371
+ const todayStart = zonedDayStartAfter(now, 0, timeZone);
372
+ const tomorrowStart = zonedDayStartAfter(now, 1, timeZone);
373
+ const dayAfterStart = zonedDayStartAfter(now, 2, timeZone);
374
+ // A day is shown only as far as the document knows it: from the later of the
375
+ // day's start (on today, now) and the document's `from`, to the earlier of
376
+ // the day's end and the document's `to`. What lies outside is not said.
377
+ const days: {
378
+ label: string;
379
+ /** The calendar day's first instant. */
380
+ dayStart: number;
381
+ /** The calendar day's end, the next day's first instant. */
382
+ dayEnd: number;
383
+ /** Where the slide starts telling. */
384
+ from: number;
385
+ /** Where the slide stops telling. */
386
+ end: number;
387
+ /** The slide tells the whole day, or on today the rest of it. */
388
+ whole: boolean;
389
+ today: boolean;
390
+ }[] = [];
391
+ const addDay = (
392
+ label: string,
393
+ dayStart: number,
394
+ dayEnd: number,
395
+ today: boolean,
396
+ ): void => {
397
+ const wanted = today ? Math.max(dayStart, now) : dayStart;
398
+ const known = Math.max(wanted, documentFrom);
399
+ const end = Math.min(dayEnd, to);
400
+ if (end > known) {
401
+ days.push({
402
+ label,
403
+ dayStart,
404
+ dayEnd,
405
+ from: known,
406
+ end,
407
+ whole: known === wanted && end === dayEnd,
408
+ today,
409
+ });
410
+ }
411
+ };
412
+ addDay(translate(locale, "today"), todayStart, tomorrowStart, true);
413
+ addDay(translate(locale, "tomorrow"), tomorrowStart, dayAfterStart, false);
414
+ const list = pagedList(document, "ul", "obrf-display-resources");
415
+ for (const resource of body.resources) {
416
+ const item = element(document, "li", "obrf-display-resource");
417
+ item.append(element(document, "p", "obrf-display-title", resource.name));
418
+ for (const day of days) {
419
+ const row = element(document, "p", "obrf-display-day");
420
+ row.append(
421
+ element(document, "span", "obrf-display-day-label", day.label),
422
+ );
423
+ const busy = resource.busy
424
+ .map((interval) => ({
425
+ start: Date.parse(interval.start),
426
+ end: Date.parse(interval.end),
427
+ }))
428
+ .filter(({ start, end }) => end > day.from && start < day.end);
429
+ if (busy.length === 0) {
430
+ row.append(
431
+ element(
432
+ document,
433
+ "span",
434
+ day.whole ? undefined : "obrf-display-data",
435
+ day.whole
436
+ ? translate(locale, day.today ? "freeRestOfDay" : "freeAllDay")
437
+ : translate(locale, "freeBetween", {
438
+ from: formatTime(day.from, locale, timeZone, "time"),
439
+ to: formatTime(day.end, locale, timeZone, "time"),
440
+ }),
441
+ ),
442
+ );
443
+ } else if (
444
+ day.whole &&
445
+ busy.length === 1 &&
446
+ busy[0] !== undefined &&
447
+ busy[0].start <= day.from &&
448
+ busy[0].end >= day.end
449
+ ) {
450
+ row.append(
451
+ element(
452
+ document,
453
+ "span",
454
+ "obrf-display-busy",
455
+ translate(locale, "busyAllDay"),
456
+ ),
457
+ );
458
+ } else {
459
+ row.append(
460
+ element(document, "span", undefined, translate(locale, "busy")),
461
+ );
462
+ for (const interval of busy) {
463
+ const start = Math.max(interval.start, day.dayStart);
464
+ const end = Math.min(interval.end, day.dayEnd);
465
+ row.append(
466
+ element(
467
+ document,
468
+ "span",
469
+ "obrf-display-busy obrf-display-data",
470
+ translate(locale, "timeRange", {
471
+ from: formatTime(start, locale, timeZone, "time"),
472
+ to:
473
+ end === day.dayEnd
474
+ ? "24:00"
475
+ : formatTime(end, locale, timeZone, "time"),
476
+ }),
477
+ ),
478
+ );
479
+ }
480
+ }
481
+ item.append(row);
482
+ }
483
+ list.append(item);
484
+ }
485
+ section.append(list);
486
+ return section;
487
+ }
488
+
489
+ function picture(body: PictureBody, context: SlideContext): HTMLElement | null {
490
+ const url = context.pictureUrl(body.picture);
491
+ if (url === null) {
492
+ return null;
493
+ }
494
+ const { document } = context;
495
+ const section = container(context, "picture");
496
+ const figure = element(document, "figure", "obrf-display-figure");
497
+ figure.append(image(body.picture, url, context));
498
+ if (body.caption !== null) {
499
+ figure.append(
500
+ element(document, "figcaption", "obrf-display-caption", body.caption),
501
+ );
502
+ }
503
+ section.append(figure);
504
+ return section;
505
+ }
506
+
507
+ function text(body: AddonTextBody, context: SlideContext): HTMLElement {
508
+ const { document } = context;
509
+ const section = container(context, "addon-text");
510
+ section.append(
511
+ element(document, "h2", "obrf-display-heading", body.heading),
512
+ ...body.body.map((paragraph) =>
513
+ element(document, "p", "obrf-display-text", paragraph),
514
+ ),
515
+ );
516
+ return section;
517
+ }
518
+
519
+ function table(body: AddonTableBody, context: SlideContext): HTMLElement {
520
+ const { document } = context;
521
+ const section = container(context, "addon-table");
522
+ const grid = element(document, "table", "obrf-display-table");
523
+ const head = element(document, "thead");
524
+ const headRow = element(document, "tr");
525
+ for (const column of body.columns) {
526
+ const cell = element(document, "th", undefined, column);
527
+ cell.scope = "col";
528
+ headRow.append(cell);
529
+ }
530
+ head.append(headRow);
531
+ const rows = pagedList(document, "tbody");
532
+ for (const row of body.rows) {
533
+ const line = element(document, "tr");
534
+ for (const cell of row) {
535
+ line.append(element(document, "td", undefined, cell));
536
+ }
537
+ rows.append(line);
538
+ }
539
+ grid.append(head, rows);
540
+ section.append(
541
+ element(document, "h2", "obrf-display-heading", body.heading),
542
+ grid,
543
+ );
544
+ return section;
545
+ }
546
+
547
+ function doors(body: AddonDoorsBody, context: SlideContext): HTMLElement {
548
+ const { document, locale } = context;
549
+ const section = container(context, "addon-doors");
550
+ const list = pagedList(document, "ul", "obrf-display-doors");
551
+ for (const door of body.doors) {
552
+ const item = element(document, "li", "obrf-display-door");
553
+ item.append(
554
+ element(document, "span", "obrf-display-door-apartment", door.apartment),
555
+ );
556
+ if (door.floor !== null) {
557
+ item.append(
558
+ element(
559
+ document,
560
+ "span",
561
+ "obrf-display-muted",
562
+ translate(locale, "floor", { floor: door.floor }),
563
+ ),
564
+ );
565
+ }
566
+ for (const name of door.names) {
567
+ item.append(element(document, "span", undefined, name));
568
+ }
569
+ list.append(item);
570
+ }
571
+ section.append(
572
+ element(document, "h2", "obrf-display-heading", body.heading),
573
+ list,
574
+ );
575
+ return section;
576
+ }
577
+
578
+ /** Whether the zone's wall clock at `time` is inside one of the windows. */
579
+ export function inSchedule(
580
+ windows: readonly { days: readonly number[]; from: string; to: string }[],
581
+ time: number,
582
+ timeZone: string,
583
+ ): boolean {
584
+ if (windows.length === 0) {
585
+ return true;
586
+ }
587
+ const parts = zonedParts(time, timeZone);
588
+ const clock = `${String(parts.hour).padStart(2, "0")}:${String(parts.minute).padStart(2, "0")}`;
589
+ return windows.some(
590
+ (window) =>
591
+ window.days.includes(parts.weekday) &&
592
+ window.from <= clock &&
593
+ clock < window.to,
594
+ );
595
+ }