@openbrf/display 0.0.0-stage → 0.1.0
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.
- package/LICENSE +661 -0
- package/LICENSE-EXCEPTION.md +28 -0
- package/README.md +109 -2
- package/dist/contract.d.ts +244 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +54 -0
- package/dist/contract.js.map +1 -0
- package/dist/fetch-loop.d.ts +87 -0
- package/dist/fetch-loop.d.ts.map +1 -0
- package/dist/fetch-loop.js +351 -0
- package/dist/fetch-loop.js.map +1 -0
- package/dist/fit.d.ts +44 -0
- package/dist/fit.d.ts.map +1 -0
- package/dist/fit.js +257 -0
- package/dist/fit.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/parse.d.ts +39 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +494 -0
- package/dist/parse.js.map +1 -0
- package/dist/renderer.d.ts +74 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +473 -0
- package/dist/renderer.js.map +1 -0
- package/dist/rotation.d.ts +129 -0
- package/dist/rotation.d.ts.map +1 -0
- package/dist/rotation.js +330 -0
- package/dist/rotation.js.map +1 -0
- package/dist/slides.d.ts +36 -0
- package/dist/slides.d.ts.map +1 -0
- package/dist/slides.js +333 -0
- package/dist/slides.js.map +1 -0
- package/dist/styles.d.ts +7 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +104 -0
- package/dist/styles.js.map +1 -0
- package/dist/text.d.ts +16 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +62 -0
- package/dist/text.js.map +1 -0
- package/dist/time.d.ts +39 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +94 -0
- package/dist/time.js.map +1 -0
- package/dist/translate.d.ts +18 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +35 -0
- package/dist/translate.js.map +1 -0
- package/dist/words.d.ts +41 -0
- package/dist/words.d.ts.map +1 -0
- package/dist/words.js +73 -0
- package/dist/words.js.map +1 -0
- package/package.json +49 -3
- package/src/contract.ts +279 -0
- package/src/fetch-loop.ts +472 -0
- package/src/fit.ts +348 -0
- package/src/index.ts +77 -0
- package/src/parse.ts +605 -0
- package/src/renderer.ts +688 -0
- package/src/rotation.ts +508 -0
- package/src/slides.ts +583 -0
- package/src/styles.ts +110 -0
- package/src/text.ts +65 -0
- package/src/time.ts +147 -0
- package/src/translate.ts +56 -0
- package/src/words.ts +91 -0
package/src/slides.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
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
|
+
/** A new element, with a class and its text. */
|
|
88
|
+
export function element<K extends keyof HTMLElementTagNameMap>(
|
|
89
|
+
document: Document,
|
|
90
|
+
tag: K,
|
|
91
|
+
className?: string,
|
|
92
|
+
text?: string,
|
|
93
|
+
): HTMLElementTagNameMap[K] {
|
|
94
|
+
const node = document.createElement(tag);
|
|
95
|
+
if (className !== undefined) {
|
|
96
|
+
node.className = className;
|
|
97
|
+
}
|
|
98
|
+
if (text !== undefined) {
|
|
99
|
+
node.textContent = text;
|
|
100
|
+
}
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A list the renderer may show a page at a time, when the canvas cannot hold all of it. */
|
|
105
|
+
function pagedList<K extends "ol" | "ul" | "tbody">(
|
|
106
|
+
document: Document,
|
|
107
|
+
tag: K,
|
|
108
|
+
className?: string,
|
|
109
|
+
): HTMLElementTagNameMap[K] {
|
|
110
|
+
const list = element(document, tag, className);
|
|
111
|
+
list.setAttribute(PAGED_LIST_ATTRIBUTE, "");
|
|
112
|
+
return list;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The slide's body, or null when it cannot be shown now (a picture slide without its picture). */
|
|
116
|
+
export function renderSlide(
|
|
117
|
+
slide: Slide,
|
|
118
|
+
context: SlideContext,
|
|
119
|
+
): HTMLElement | null {
|
|
120
|
+
const { body } = slide;
|
|
121
|
+
switch (body.kind) {
|
|
122
|
+
case "news":
|
|
123
|
+
return news(body, context);
|
|
124
|
+
case "events":
|
|
125
|
+
return events(body, context);
|
|
126
|
+
case "meeting":
|
|
127
|
+
return meeting(body, context);
|
|
128
|
+
case "availability":
|
|
129
|
+
return availability(body, context);
|
|
130
|
+
case "picture":
|
|
131
|
+
return picture(body, context);
|
|
132
|
+
case "text":
|
|
133
|
+
return text(body, context);
|
|
134
|
+
case "table":
|
|
135
|
+
return table(body, context);
|
|
136
|
+
case "doors":
|
|
137
|
+
return doors(body, context);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function container(context: SlideContext, kind: string): HTMLElement {
|
|
142
|
+
return element(
|
|
143
|
+
context.document,
|
|
144
|
+
"section",
|
|
145
|
+
`obrf-display-slide obrf-display-${kind}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function image(ref: PictureRef, url: string, context: SlideContext) {
|
|
150
|
+
const img = element(context.document, "img", "obrf-display-picture");
|
|
151
|
+
img.alt = ref.alt;
|
|
152
|
+
img.decoding = "async";
|
|
153
|
+
img.addEventListener("error", () => context.pictureFailed(ref), {
|
|
154
|
+
once: true,
|
|
155
|
+
});
|
|
156
|
+
img.src = url;
|
|
157
|
+
return img;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function news(body: NewsBody, context: SlideContext): HTMLElement {
|
|
161
|
+
const { document } = context;
|
|
162
|
+
const section = container(context, "news");
|
|
163
|
+
const copy = element(document, "div", "obrf-display-copy");
|
|
164
|
+
const published = Date.parse(body.publishedAt);
|
|
165
|
+
copy.append(
|
|
166
|
+
element(
|
|
167
|
+
document,
|
|
168
|
+
"p",
|
|
169
|
+
"obrf-display-date",
|
|
170
|
+
formatTime(published, context.locale, context.timeZone, "date"),
|
|
171
|
+
),
|
|
172
|
+
element(document, "h2", "obrf-display-heading", body.title),
|
|
173
|
+
...body.paragraphs.map((paragraph) =>
|
|
174
|
+
element(document, "p", "obrf-display-text", paragraph),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
section.append(copy);
|
|
178
|
+
const url = body.picture === null ? null : context.pictureUrl(body.picture);
|
|
179
|
+
if (body.picture !== null && url !== null) {
|
|
180
|
+
section.classList.add("obrf-display-news--picture");
|
|
181
|
+
section.append(image(body.picture, url, context));
|
|
182
|
+
}
|
|
183
|
+
return section;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function timeRange(start: number, end: number, context: SlideContext): string {
|
|
187
|
+
const { locale, timeZone } = context;
|
|
188
|
+
const from = formatTime(start, locale, timeZone, "time");
|
|
189
|
+
const sameDay =
|
|
190
|
+
zonedDayStartAfter(start, 0, timeZone) ===
|
|
191
|
+
zonedDayStartAfter(end, 0, timeZone);
|
|
192
|
+
const to = sameDay
|
|
193
|
+
? formatTime(end, locale, timeZone, "time")
|
|
194
|
+
: `${formatTime(end, locale, timeZone, "dayShort")} ${formatTime(end, locale, timeZone, "time")}`;
|
|
195
|
+
return translate(locale, "timeRange", { from, to });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function events(body: EventsBody, context: SlideContext): HTMLElement {
|
|
199
|
+
const { document, locale, timeZone } = context;
|
|
200
|
+
const section = container(context, "events");
|
|
201
|
+
section.append(
|
|
202
|
+
element(
|
|
203
|
+
document,
|
|
204
|
+
"h2",
|
|
205
|
+
"obrf-display-heading",
|
|
206
|
+
translate(locale, "comingEvents"),
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
const list = pagedList(document, "ol", "obrf-display-list");
|
|
210
|
+
for (const occurrence of body.occurrences) {
|
|
211
|
+
const start = Date.parse(occurrence.startsAt);
|
|
212
|
+
const end = Date.parse(occurrence.endsAt);
|
|
213
|
+
const item = element(document, "li", "obrf-display-event");
|
|
214
|
+
const when = element(document, "div", "obrf-display-when");
|
|
215
|
+
when.append(
|
|
216
|
+
element(
|
|
217
|
+
document,
|
|
218
|
+
"span",
|
|
219
|
+
undefined,
|
|
220
|
+
capitalised(formatTime(start, locale, timeZone, "dayShort")),
|
|
221
|
+
),
|
|
222
|
+
element(document, "span", undefined, timeRange(start, end, context)),
|
|
223
|
+
);
|
|
224
|
+
const what = element(document, "div", "obrf-display-what");
|
|
225
|
+
what.append(element(document, "p", "obrf-display-title", occurrence.title));
|
|
226
|
+
if (occurrence.location !== null) {
|
|
227
|
+
what.append(
|
|
228
|
+
element(
|
|
229
|
+
document,
|
|
230
|
+
"p",
|
|
231
|
+
"obrf-display-text obrf-display-muted",
|
|
232
|
+
occurrence.location,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (occurrence.description !== null) {
|
|
237
|
+
what.append(
|
|
238
|
+
element(
|
|
239
|
+
document,
|
|
240
|
+
"p",
|
|
241
|
+
"obrf-display-description",
|
|
242
|
+
occurrence.description,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (occurrence.calledOff) {
|
|
247
|
+
item.classList.add("obrf-display-event--called-off");
|
|
248
|
+
what.append(
|
|
249
|
+
element(
|
|
250
|
+
document,
|
|
251
|
+
"span",
|
|
252
|
+
"obrf-display-badge",
|
|
253
|
+
translate(locale, "calledOff"),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
} else if (occurrence.placesLeft !== null) {
|
|
257
|
+
what.append(
|
|
258
|
+
element(
|
|
259
|
+
document,
|
|
260
|
+
"p",
|
|
261
|
+
"obrf-display-text",
|
|
262
|
+
occurrence.placesLeft === 0
|
|
263
|
+
? translate(locale, "fullyBooked")
|
|
264
|
+
: translate(locale, "placesLeft", { count: occurrence.placesLeft }),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
item.append(when, what);
|
|
269
|
+
list.append(item);
|
|
270
|
+
}
|
|
271
|
+
section.append(list);
|
|
272
|
+
return section;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function meeting(body: MeetingBody, context: SlideContext): HTMLElement {
|
|
276
|
+
const { document, locale, timeZone } = context;
|
|
277
|
+
const section = container(context, "meeting");
|
|
278
|
+
section.append(
|
|
279
|
+
element(
|
|
280
|
+
document,
|
|
281
|
+
"h2",
|
|
282
|
+
"obrf-display-heading",
|
|
283
|
+
translate(
|
|
284
|
+
locale,
|
|
285
|
+
body.meetingKind === "extraordinary"
|
|
286
|
+
? "extraordinaryMeeting"
|
|
287
|
+
: "ordinaryMeeting",
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
const facts = element(document, "dl", "obrf-display-facts");
|
|
292
|
+
const fact = (term: string, value: string, className?: string) => {
|
|
293
|
+
facts.append(
|
|
294
|
+
element(document, "dt", undefined, term),
|
|
295
|
+
element(document, "dd", className, value),
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
fact(
|
|
299
|
+
translate(locale, "meetingWhen"),
|
|
300
|
+
capitalised(
|
|
301
|
+
formatTime(
|
|
302
|
+
Date.parse(body.startsAt),
|
|
303
|
+
locale,
|
|
304
|
+
timeZone,
|
|
305
|
+
"weekdayDateTime",
|
|
306
|
+
),
|
|
307
|
+
),
|
|
308
|
+
"obrf-display-data",
|
|
309
|
+
);
|
|
310
|
+
fact(translate(locale, "meetingWhere"), body.place);
|
|
311
|
+
if (body.digitalParticipation !== null) {
|
|
312
|
+
fact(translate(locale, "digitalParticipation"), body.digitalParticipation);
|
|
313
|
+
}
|
|
314
|
+
section.append(facts);
|
|
315
|
+
if (body.agenda.length > 0) {
|
|
316
|
+
const agenda = pagedList(document, "ol", "obrf-display-agenda");
|
|
317
|
+
for (const item of body.agenda) {
|
|
318
|
+
const entry = element(document, "li");
|
|
319
|
+
entry.append(
|
|
320
|
+
element(
|
|
321
|
+
document,
|
|
322
|
+
"span",
|
|
323
|
+
"obrf-display-agenda-position",
|
|
324
|
+
`${item.position}.`,
|
|
325
|
+
),
|
|
326
|
+
element(document, "span", undefined, item.title),
|
|
327
|
+
);
|
|
328
|
+
agenda.append(entry);
|
|
329
|
+
}
|
|
330
|
+
section.append(
|
|
331
|
+
element(
|
|
332
|
+
document,
|
|
333
|
+
"h3",
|
|
334
|
+
"obrf-display-subheading",
|
|
335
|
+
translate(locale, "agenda"),
|
|
336
|
+
),
|
|
337
|
+
agenda,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return section;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function availability(
|
|
344
|
+
body: AvailabilityBody,
|
|
345
|
+
context: SlideContext,
|
|
346
|
+
): HTMLElement {
|
|
347
|
+
const { document, locale, timeZone, now } = context;
|
|
348
|
+
const section = container(context, "availability");
|
|
349
|
+
section.append(
|
|
350
|
+
element(
|
|
351
|
+
document,
|
|
352
|
+
"h2",
|
|
353
|
+
"obrf-display-heading",
|
|
354
|
+
translate(locale, "freeAndBusy"),
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
const documentFrom = Date.parse(body.from);
|
|
358
|
+
const to = Date.parse(body.to);
|
|
359
|
+
const todayStart = zonedDayStartAfter(now, 0, timeZone);
|
|
360
|
+
const tomorrowStart = zonedDayStartAfter(now, 1, timeZone);
|
|
361
|
+
const dayAfterStart = zonedDayStartAfter(now, 2, timeZone);
|
|
362
|
+
// A day is shown only as far as the document knows it: from the later of the
|
|
363
|
+
// day's start (on today, now) and the document's `from`, to the earlier of
|
|
364
|
+
// the day's end and the document's `to`. What lies outside is not said.
|
|
365
|
+
const days: {
|
|
366
|
+
label: string;
|
|
367
|
+
/** The calendar day's first instant. */
|
|
368
|
+
dayStart: number;
|
|
369
|
+
/** The calendar day's end, the next day's first instant. */
|
|
370
|
+
dayEnd: number;
|
|
371
|
+
/** Where the slide starts telling. */
|
|
372
|
+
from: number;
|
|
373
|
+
/** Where the slide stops telling. */
|
|
374
|
+
end: number;
|
|
375
|
+
/** The slide tells the whole day, or on today the rest of it. */
|
|
376
|
+
whole: boolean;
|
|
377
|
+
today: boolean;
|
|
378
|
+
}[] = [];
|
|
379
|
+
const addDay = (
|
|
380
|
+
label: string,
|
|
381
|
+
dayStart: number,
|
|
382
|
+
dayEnd: number,
|
|
383
|
+
today: boolean,
|
|
384
|
+
): void => {
|
|
385
|
+
const wanted = today ? Math.max(dayStart, now) : dayStart;
|
|
386
|
+
const known = Math.max(wanted, documentFrom);
|
|
387
|
+
const end = Math.min(dayEnd, to);
|
|
388
|
+
if (end > known) {
|
|
389
|
+
days.push({
|
|
390
|
+
label,
|
|
391
|
+
dayStart,
|
|
392
|
+
dayEnd,
|
|
393
|
+
from: known,
|
|
394
|
+
end,
|
|
395
|
+
whole: known === wanted && end === dayEnd,
|
|
396
|
+
today,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
addDay(translate(locale, "today"), todayStart, tomorrowStart, true);
|
|
401
|
+
addDay(translate(locale, "tomorrow"), tomorrowStart, dayAfterStart, false);
|
|
402
|
+
const list = pagedList(document, "ul", "obrf-display-resources");
|
|
403
|
+
for (const resource of body.resources) {
|
|
404
|
+
const item = element(document, "li", "obrf-display-resource");
|
|
405
|
+
item.append(element(document, "p", "obrf-display-title", resource.name));
|
|
406
|
+
for (const day of days) {
|
|
407
|
+
const row = element(document, "p", "obrf-display-day");
|
|
408
|
+
row.append(
|
|
409
|
+
element(document, "span", "obrf-display-day-label", day.label),
|
|
410
|
+
);
|
|
411
|
+
const busy = resource.busy
|
|
412
|
+
.map((interval) => ({
|
|
413
|
+
start: Date.parse(interval.start),
|
|
414
|
+
end: Date.parse(interval.end),
|
|
415
|
+
}))
|
|
416
|
+
.filter(({ start, end }) => end > day.from && start < day.end);
|
|
417
|
+
if (busy.length === 0) {
|
|
418
|
+
row.append(
|
|
419
|
+
element(
|
|
420
|
+
document,
|
|
421
|
+
"span",
|
|
422
|
+
undefined,
|
|
423
|
+
day.whole
|
|
424
|
+
? translate(locale, day.today ? "freeRestOfDay" : "freeAllDay")
|
|
425
|
+
: translate(locale, "freeBetween", {
|
|
426
|
+
from: formatTime(day.from, locale, timeZone, "time"),
|
|
427
|
+
to: formatTime(day.end, locale, timeZone, "time"),
|
|
428
|
+
}),
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
} else if (
|
|
432
|
+
day.whole &&
|
|
433
|
+
busy.length === 1 &&
|
|
434
|
+
busy[0] !== undefined &&
|
|
435
|
+
busy[0].start <= day.from &&
|
|
436
|
+
busy[0].end >= day.end
|
|
437
|
+
) {
|
|
438
|
+
row.append(
|
|
439
|
+
element(
|
|
440
|
+
document,
|
|
441
|
+
"span",
|
|
442
|
+
"obrf-display-busy",
|
|
443
|
+
translate(locale, "busyAllDay"),
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
row.append(
|
|
448
|
+
element(document, "span", undefined, translate(locale, "busy")),
|
|
449
|
+
);
|
|
450
|
+
for (const interval of busy) {
|
|
451
|
+
const start = Math.max(interval.start, day.dayStart);
|
|
452
|
+
const end = Math.min(interval.end, day.dayEnd);
|
|
453
|
+
row.append(
|
|
454
|
+
element(
|
|
455
|
+
document,
|
|
456
|
+
"span",
|
|
457
|
+
"obrf-display-busy obrf-display-data",
|
|
458
|
+
translate(locale, "timeRange", {
|
|
459
|
+
from: formatTime(start, locale, timeZone, "time"),
|
|
460
|
+
to:
|
|
461
|
+
end === day.dayEnd
|
|
462
|
+
? "24:00"
|
|
463
|
+
: formatTime(end, locale, timeZone, "time"),
|
|
464
|
+
}),
|
|
465
|
+
),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
item.append(row);
|
|
470
|
+
}
|
|
471
|
+
list.append(item);
|
|
472
|
+
}
|
|
473
|
+
section.append(list);
|
|
474
|
+
return section;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function picture(body: PictureBody, context: SlideContext): HTMLElement | null {
|
|
478
|
+
const url = context.pictureUrl(body.picture);
|
|
479
|
+
if (url === null) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
const { document } = context;
|
|
483
|
+
const section = container(context, "picture");
|
|
484
|
+
const figure = element(document, "figure", "obrf-display-figure");
|
|
485
|
+
figure.append(image(body.picture, url, context));
|
|
486
|
+
if (body.caption !== null) {
|
|
487
|
+
figure.append(
|
|
488
|
+
element(document, "figcaption", "obrf-display-caption", body.caption),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
section.append(figure);
|
|
492
|
+
return section;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function text(body: AddonTextBody, context: SlideContext): HTMLElement {
|
|
496
|
+
const { document } = context;
|
|
497
|
+
const section = container(context, "addon-text");
|
|
498
|
+
section.append(
|
|
499
|
+
element(document, "h2", "obrf-display-heading", body.heading),
|
|
500
|
+
...body.body.map((paragraph) =>
|
|
501
|
+
element(document, "p", "obrf-display-text", paragraph),
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
return section;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function table(body: AddonTableBody, context: SlideContext): HTMLElement {
|
|
508
|
+
const { document } = context;
|
|
509
|
+
const section = container(context, "addon-table");
|
|
510
|
+
const grid = element(document, "table", "obrf-display-table");
|
|
511
|
+
const head = element(document, "thead");
|
|
512
|
+
const headRow = element(document, "tr");
|
|
513
|
+
for (const column of body.columns) {
|
|
514
|
+
const cell = element(document, "th", undefined, column);
|
|
515
|
+
cell.scope = "col";
|
|
516
|
+
headRow.append(cell);
|
|
517
|
+
}
|
|
518
|
+
head.append(headRow);
|
|
519
|
+
const rows = pagedList(document, "tbody");
|
|
520
|
+
for (const row of body.rows) {
|
|
521
|
+
const line = element(document, "tr");
|
|
522
|
+
for (const cell of row) {
|
|
523
|
+
line.append(element(document, "td", undefined, cell));
|
|
524
|
+
}
|
|
525
|
+
rows.append(line);
|
|
526
|
+
}
|
|
527
|
+
grid.append(head, rows);
|
|
528
|
+
section.append(
|
|
529
|
+
element(document, "h2", "obrf-display-heading", body.heading),
|
|
530
|
+
grid,
|
|
531
|
+
);
|
|
532
|
+
return section;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function doors(body: AddonDoorsBody, context: SlideContext): HTMLElement {
|
|
536
|
+
const { document, locale } = context;
|
|
537
|
+
const section = container(context, "addon-doors");
|
|
538
|
+
const list = pagedList(document, "ul", "obrf-display-doors");
|
|
539
|
+
for (const door of body.doors) {
|
|
540
|
+
const item = element(document, "li", "obrf-display-door");
|
|
541
|
+
item.append(
|
|
542
|
+
element(document, "span", "obrf-display-door-apartment", door.apartment),
|
|
543
|
+
);
|
|
544
|
+
if (door.floor !== null) {
|
|
545
|
+
item.append(
|
|
546
|
+
element(
|
|
547
|
+
document,
|
|
548
|
+
"span",
|
|
549
|
+
"obrf-display-muted",
|
|
550
|
+
translate(locale, "floor", { floor: door.floor }),
|
|
551
|
+
),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
for (const name of door.names) {
|
|
555
|
+
item.append(element(document, "span", undefined, name));
|
|
556
|
+
}
|
|
557
|
+
list.append(item);
|
|
558
|
+
}
|
|
559
|
+
section.append(
|
|
560
|
+
element(document, "h2", "obrf-display-heading", body.heading),
|
|
561
|
+
list,
|
|
562
|
+
);
|
|
563
|
+
return section;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Whether the zone's wall clock at `time` is inside one of the windows. */
|
|
567
|
+
export function inSchedule(
|
|
568
|
+
windows: readonly { days: readonly number[]; from: string; to: string }[],
|
|
569
|
+
time: number,
|
|
570
|
+
timeZone: string,
|
|
571
|
+
): boolean {
|
|
572
|
+
if (windows.length === 0) {
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
const parts = zonedParts(time, timeZone);
|
|
576
|
+
const clock = `${String(parts.hour).padStart(2, "0")}:${String(parts.minute).padStart(2, "0")}`;
|
|
577
|
+
return windows.some(
|
|
578
|
+
(window) =>
|
|
579
|
+
window.days.includes(parts.weekday) &&
|
|
580
|
+
window.from <= clock &&
|
|
581
|
+
clock < window.to,
|
|
582
|
+
);
|
|
583
|
+
}
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderer's stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Its colours, type, shape and motion are the default theme's (Porttavlan,
|
|
5
|
+
* from `@openbrf/tokens`), so a screen looks like the association's own pages,
|
|
6
|
+
* and every pair it sets on another is one the token contract holds to WCAG
|
|
7
|
+
* AA. Dates, times and apartment numbers are in the data face, as everywhere
|
|
8
|
+
* else (DESIGN.md, the Mono-Grid Rule).
|
|
9
|
+
* A screen is read from across a stairwell, so every size is a fraction of the
|
|
10
|
+
* canvas's shorter side (`--obrf-display-u`, a hundredth of it), which the
|
|
11
|
+
* renderer sets from the element it is mounted on.
|
|
12
|
+
*
|
|
13
|
+
* Every class starts with `obrf-display`, so the sheet restyles nothing of the
|
|
14
|
+
* page it is mounted in.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
PORTTAVLAN_DARK,
|
|
18
|
+
PORTTAVLAN_LIGHT,
|
|
19
|
+
type TokenSet,
|
|
20
|
+
} from "@openbrf/tokens";
|
|
21
|
+
import { ELSEWHERE_ATTRIBUTE } from "./fit.ts";
|
|
22
|
+
|
|
23
|
+
function palette(tokens: TokenSet): string {
|
|
24
|
+
return [
|
|
25
|
+
`--obrf-display-ground: ${tokens["surface-page"]};`,
|
|
26
|
+
`--obrf-display-panel: ${tokens["surface-raised"]};`,
|
|
27
|
+
`--obrf-display-ink: ${tokens["text-primary"]};`,
|
|
28
|
+
`--obrf-display-muted: ${tokens["text-secondary"]};`,
|
|
29
|
+
`--obrf-display-accent: ${tokens["accent-trust"]};`,
|
|
30
|
+
`--obrf-display-danger: ${tokens["status-danger"]};`,
|
|
31
|
+
`--obrf-display-on-danger: ${tokens["on-status-danger"]};`,
|
|
32
|
+
`--obrf-display-rule: ${tokens["border-subtle"]};`,
|
|
33
|
+
`--obrf-display-font: ${tokens["font-ui"]};`,
|
|
34
|
+
`--obrf-display-data-font: ${tokens["font-data"]};`,
|
|
35
|
+
`--obrf-display-radius-control: ${tokens["radius-control"]};`,
|
|
36
|
+
`--obrf-display-radius-panel: ${tokens["radius-panel"]};`,
|
|
37
|
+
`--obrf-display-motion-duration: ${tokens["motion-duration"]};`,
|
|
38
|
+
`--obrf-display-motion-easing: ${tokens["motion-easing"]};`,
|
|
39
|
+
].join(" ");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const u = (times: number): string => `calc(var(--obrf-display-u) * ${times})`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The stylesheet `mountRenderer` adds to the document once, as a `<style>`
|
|
46
|
+
* element with the attribute `data-obrf-display`. Exported for a page that
|
|
47
|
+
* would rather serve it as a file of its own.
|
|
48
|
+
*/
|
|
49
|
+
export const DISPLAY_STYLESHEET = `
|
|
50
|
+
.obrf-display { --obrf-display-u: 1vmin; position: relative; width: 100%; height: 100%; overflow: hidden; background: var(--obrf-display-ground); color: var(--obrf-display-ink); font-family: var(--obrf-display-font); }
|
|
51
|
+
.obrf-display--light { ${palette(PORTTAVLAN_LIGHT)} color-scheme: light; }
|
|
52
|
+
.obrf-display--dark { ${palette(PORTTAVLAN_DARK)} color-scheme: dark; }
|
|
53
|
+
.obrf-display [hidden] { display: none !important; }
|
|
54
|
+
.obrf-display [${ELSEWHERE_ATTRIBUTE}]:not(td, th) { display: none !important; }
|
|
55
|
+
.obrf-display *, .obrf-display *::before, .obrf-display *::after { box-sizing: border-box; }
|
|
56
|
+
.obrf-display-canvas { position: absolute; left: 50%; top: 50%; width: 100%; height: 100%; transform: translate(-50%, -50%); display: flex; flex-direction: column; gap: ${u(2.5)}; padding: ${u(4)} ${u(5)}; font-size: ${u(3.4)}; line-height: 1.3; }
|
|
57
|
+
.obrf-display-clock { display: flex; justify-content: space-between; align-items: baseline; gap: ${u(3)}; padding-bottom: ${u(1.5)}; border-bottom: ${u(0.25)} solid var(--obrf-display-rule); color: var(--obrf-display-muted); font-size: ${u(3.6)}; }
|
|
58
|
+
.obrf-display-clock, .obrf-display-date, .obrf-display-when, .obrf-display-door-apartment, .obrf-display-data { font-family: var(--obrf-display-data-font); }
|
|
59
|
+
.obrf-display-clock-time { color: var(--obrf-display-ink); font-size: ${u(5)}; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
60
|
+
.obrf-display-stage { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
|
|
61
|
+
.obrf-display-slide { display: flex; flex-direction: column; gap: ${u(2.5)}; height: 100%; min-height: 0; overflow: hidden; font-size: ${u(3.4)}; }
|
|
62
|
+
.obrf-display-slide--enter { animation: obrf-display-enter var(--obrf-display-motion-duration) var(--obrf-display-motion-easing); }
|
|
63
|
+
@keyframes obrf-display-enter { from { opacity: 0; } to { opacity: 1; } }
|
|
64
|
+
@media (prefers-reduced-motion: reduce) { .obrf-display-slide--enter { animation: none; } }
|
|
65
|
+
.obrf-display-heading { margin: 0; font-size: ${u(6.5)}; font-weight: 700; line-height: 1.1; overflow-wrap: anywhere; }
|
|
66
|
+
.obrf-display-subheading { margin: 0; font-size: ${u(4)}; font-weight: 700; color: var(--obrf-display-muted); }
|
|
67
|
+
.obrf-display-text { margin: 0; white-space: pre-line; overflow-wrap: anywhere; }
|
|
68
|
+
.obrf-display-muted { color: var(--obrf-display-muted); }
|
|
69
|
+
.obrf-display-date { margin: 0; color: var(--obrf-display-accent); font-weight: 700; }
|
|
70
|
+
.obrf-display-news { display: grid; grid-template-columns: minmax(0, 1fr); align-content: start; }
|
|
71
|
+
.obrf-display-news--picture { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: ${u(5)}; }
|
|
72
|
+
.obrf-display--portrait .obrf-display-news--picture { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
|
|
73
|
+
.obrf-display-copy { display: flex; flex-direction: column; gap: ${u(2)}; min-height: 0; overflow: hidden; }
|
|
74
|
+
.obrf-display-picture { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
|
|
75
|
+
.obrf-display-figure { display: flex; flex-direction: column; gap: ${u(2)}; height: 100%; margin: 0; }
|
|
76
|
+
.obrf-display-figure .obrf-display-picture { flex: 1 1 auto; }
|
|
77
|
+
.obrf-display-caption { font-size: ${u(4)}; text-align: center; }
|
|
78
|
+
.obrf-display-list { display: flex; flex-direction: column; gap: ${u(2)}; margin: 0; padding: 0; list-style: none; }
|
|
79
|
+
.obrf-display-event { display: grid; grid-template-columns: ${u(24)} minmax(0, 1fr); gap: ${u(3)}; padding-bottom: ${u(1.5)}; border-bottom: ${u(0.2)} solid var(--obrf-display-rule); }
|
|
80
|
+
.obrf-display-when { display: flex; flex-direction: column; color: var(--obrf-display-accent); font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
81
|
+
.obrf-display-what { grid-column: 2; display: flex; flex-direction: column; gap: ${u(0.5)}; min-width: 0; }
|
|
82
|
+
.obrf-display-title { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
|
|
83
|
+
.obrf-display-event--called-off .obrf-display-title { text-decoration: line-through; }
|
|
84
|
+
.obrf-display-description { margin: 0; color: var(--obrf-display-muted); font-size: ${u(2.8)}; white-space: pre-line; overflow-wrap: anywhere; }
|
|
85
|
+
.obrf-display-badge { align-self: flex-start; padding: 0 ${u(1)}; border-radius: var(--obrf-display-radius-control); background: var(--obrf-display-danger); color: var(--obrf-display-on-danger); font-weight: 700; }
|
|
86
|
+
.obrf-display-facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: ${u(1)} ${u(3)}; margin: 0; }
|
|
87
|
+
.obrf-display-facts dt { grid-column: 1; color: var(--obrf-display-muted); }
|
|
88
|
+
.obrf-display-facts dd { grid-column: 2; margin: 0; font-weight: 700; white-space: pre-line; overflow-wrap: anywhere; }
|
|
89
|
+
.obrf-display-agenda { margin: 0; padding: 0; list-style: none; columns: 2; column-gap: ${u(5)}; font-size: ${u(3)}; }
|
|
90
|
+
.obrf-display--portrait .obrf-display-agenda { columns: 1; }
|
|
91
|
+
.obrf-display-agenda li { break-inside: avoid; display: flex; gap: ${u(1.5)}; }
|
|
92
|
+
.obrf-display-agenda-position { min-width: ${u(4)}; color: var(--obrf-display-muted); font-variant-numeric: tabular-nums; }
|
|
93
|
+
.obrf-display-resources { display: grid; grid-template-columns: repeat(auto-fill, minmax(${u(52)}, 1fr)); gap: ${u(2)}; margin: 0; padding: 0; list-style: none; }
|
|
94
|
+
.obrf-display-resource { display: flex; flex-direction: column; gap: ${u(0.8)}; padding: ${u(1.5)} ${u(2)}; border-radius: var(--obrf-display-radius-panel); background: var(--obrf-display-panel); }
|
|
95
|
+
.obrf-display-day { display: flex; margin: 0; flex-wrap: wrap; gap: ${u(0.5)} ${u(1.5)}; font-size: ${u(2.8)}; }
|
|
96
|
+
.obrf-display-day-label { min-width: ${u(14)}; color: var(--obrf-display-muted); }
|
|
97
|
+
.obrf-display-busy { font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
98
|
+
.obrf-display-table { width: 100%; border-collapse: collapse; }
|
|
99
|
+
.obrf-display-table th, .obrf-display-table td { padding: ${u(0.8)} ${u(1.5)}; border-bottom: ${u(0.2)} solid var(--obrf-display-rule); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
|
100
|
+
.obrf-display-table th { color: var(--obrf-display-muted); }
|
|
101
|
+
.obrf-display-doors { display: grid; grid-template-columns: repeat(auto-fill, minmax(${u(30)}, 1fr)); gap: ${u(1.5)}; margin: 0; padding: 0; list-style: none; font-size: ${u(2.8)}; }
|
|
102
|
+
.obrf-display-door { display: flex; flex-direction: column; padding: ${u(1)} ${u(1.5)}; border-radius: var(--obrf-display-radius-panel); background: var(--obrf-display-panel); }
|
|
103
|
+
.obrf-display-door-apartment { font-weight: 700; }
|
|
104
|
+
.obrf-display-message { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: ${u(3)}; height: 100%; text-align: center; }
|
|
105
|
+
.obrf-display-message-title { margin: 0; font-size: ${u(7)}; font-weight: 700; }
|
|
106
|
+
.obrf-display-empty { display: flex; align-items: center; justify-content: center; height: 100%; margin: 0; color: var(--obrf-display-muted); font-size: ${u(4)}; }
|
|
107
|
+
.obrf-display-message-detail { max-width: 40ch; margin: 0; color: var(--obrf-display-muted); font-size: ${u(4)}; }
|
|
108
|
+
.obrf-display-note { position: absolute; right: ${u(2)}; bottom: ${u(1.2)}; color: var(--obrf-display-muted); font-size: ${u(2.2)}; }
|
|
109
|
+
.obrf-display-off { position: absolute; inset: 0; z-index: 1; background: black; }
|
|
110
|
+
`;
|