@ironflock/widget-value 0.0.0-stage → 1.1.34

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.
@@ -0,0 +1,449 @@
1
+ import { html, css, LitElement, PropertyValueMap, PropertyValues } from 'lit'
2
+ import { repeat } from 'lit/directives/repeat.js'
3
+ import { customElement, property, query, state } from 'lit/decorators.js'
4
+ import { ValueDisplayConfiguration } from './definition-schema.js'
5
+
6
+ type Dataseries = Exclude<ValueDisplayConfiguration['dataseries'], undefined>[number] & { needleValue?: number }
7
+ type Data = Exclude<Dataseries['data'], undefined>[number]
8
+ type Theme = {
9
+ theme_name: string
10
+ theme_object: any
11
+ }
12
+
13
+ // No locale and no time zone given: both are the viewer's own.
14
+ const localDateTime = new Intl.DateTimeFormat(undefined, {
15
+ year: 'numeric',
16
+ month: 'short',
17
+ day: 'numeric',
18
+ hour: '2-digit',
19
+ minute: '2-digit'
20
+ })
21
+
22
+ // A bound timestamp column arrives as epoch milliseconds; a string column or a
23
+ // static subtitle may hold ISO 8601 instead. Anything else is not a point in
24
+ // time and comes back undefined, so the caller can show it as given.
25
+ const toDate = (value: unknown): Date | undefined => {
26
+ if (value === null || value === undefined || value === '') return undefined
27
+ const ms = /^\s*-?\d+(\.\d+)?\s*$/.test(String(value)) ? Number(value) : Date.parse(String(value))
28
+ return Number.isFinite(ms) ? new Date(ms) : undefined
29
+ }
30
+
31
+ @customElement('widget-value-versionplaceholder')
32
+ export class WidgetValue extends LitElement {
33
+ @property({ type: Object })
34
+ inputData?: ValueDisplayConfiguration
35
+
36
+ @property({ type: Object })
37
+ theme?: Theme
38
+
39
+ @state()
40
+ private dataSets: Map<string, Dataseries> = new Map()
41
+
42
+ @state()
43
+ private textActive: boolean = true
44
+
45
+ @state() private themeBgColor?: string
46
+ @state() private themeTitleColor?: string
47
+ @state() private themeSubtitleColor?: string
48
+
49
+ @query('.value-container')
50
+ valueContainer?: HTMLDivElement
51
+
52
+ @query('.sizing-container')
53
+ sizingContainer?: HTMLDivElement
54
+
55
+ version: string = 'versionplaceholder'
56
+
57
+ private resizeObserver: ResizeObserver
58
+ private lastLargerWidthTime?: number
59
+ private origWidth: number = 0
60
+ private origHeight: number = 0
61
+
62
+ constructor() {
63
+ super()
64
+ this.resizeObserver = new ResizeObserver(this.applyData.bind(this))
65
+ this.resizeObserver.observe(this)
66
+ }
67
+
68
+ disconnectedCallback() {
69
+ super.disconnectedCallback()
70
+ if (this.resizeObserver) {
71
+ this.resizeObserver.disconnect()
72
+ }
73
+ }
74
+
75
+ protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
76
+ this.registerTheme(this.theme)
77
+ }
78
+
79
+ protected update(changedProperties: PropertyValues): void {
80
+ if (changedProperties.has('inputData')) {
81
+ this.transformData()
82
+ }
83
+ super.update(changedProperties)
84
+ }
85
+
86
+ protected updated(changedProperties: Map<string, any>) {
87
+ if (changedProperties.has('inputData')) {
88
+ this.sizingSetup()
89
+ this.applyData()
90
+ }
91
+
92
+ if (changedProperties.has('theme')) {
93
+ this.registerTheme(this.theme)
94
+ }
95
+
96
+ super.updated(changedProperties)
97
+ }
98
+
99
+ registerTheme(theme?: Theme) {
100
+ // The host's --re-tile-background-color / --re-text-color are not read
101
+ // through getComputedStyle here. That snapshots them, and this only runs
102
+ // on a `theme` property change, so a board style edit — which changes the
103
+ // custom property and nothing else — left the tile painting the previous
104
+ // colour until it was reloaded. What these fields hold is a var() chain
105
+ // with the theme's own colour as the fallback, so the host property keeps
106
+ // winning over the theme, and the browser resolves it on every repaint
107
+ // without the widget being told anything.
108
+ const themeBg = this.theme?.theme_object?.backgroundColor
109
+ const themeText = this.theme?.theme_object?.title?.textStyle?.color
110
+ const themeSubtext = this.theme?.theme_object?.title?.subtextStyle?.color || themeText
111
+
112
+ this.themeBgColor = `var(--re-tile-background-color, ${themeBg ?? 'transparent'})`
113
+ this.themeTitleColor = `var(--re-text-color, ${themeText ?? 'inherit'})`
114
+ this.themeSubtitleColor = `var(--re-text-color, ${themeSubtext ?? 'inherit'})`
115
+ }
116
+
117
+ subTitleText() {
118
+ const subTitle = this.inputData?.subTitle
119
+ if (this.inputData?.subTitleType !== 'timestamp') return subTitle
120
+ const date = toDate(subTitle)
121
+ return date ? localDateTime.format(date) : subTitle
122
+ }
123
+
124
+ sizingSetup() {
125
+ const boxes = Array.from(
126
+ this.sizingContainer?.querySelectorAll('.single-value') as NodeListOf<HTMLDivElement>
127
+ )
128
+
129
+ let width = 0
130
+ let height = 0
131
+ for (const box of boxes) {
132
+ const dims = box.getBoundingClientRect()
133
+ width = Math.max(dims.width, width)
134
+ height = Math.max(dims.height, height)
135
+ }
136
+
137
+ if (width < this.origWidth) {
138
+ if (!this.lastLargerWidthTime || Date.now() - this.lastLargerWidthTime > 5000) {
139
+ this.origWidth = width
140
+ }
141
+ } else {
142
+ this.origWidth = width
143
+ this.lastLargerWidthTime = Date.now()
144
+ }
145
+ this.origHeight = height
146
+ }
147
+
148
+ applyData() {
149
+ if (!this.valueContainer) return
150
+ const userWidth = this.valueContainer.getBoundingClientRect().width
151
+ const userHeight = this.valueContainer.getBoundingClientRect().height
152
+ const count = this.dataSets.size
153
+
154
+ const width = this.origWidth
155
+ const height = this.origHeight
156
+ if (!userWidth || !userHeight || !width || !height) return
157
+
158
+ const fits = []
159
+ for (let c = 1; c <= count; c++) {
160
+ const r = Math.ceil(count / c)
161
+ const uwgap = userWidth - 12 * (c - 1)
162
+ const uhgap = userHeight - 12 * (r - 1)
163
+ const m = uwgap / width / c
164
+ const size = m * m * width * height * count
165
+ if (r * m * height <= uhgap) fits.push({ r, c, m, size, width, height, userWidth, userHeight })
166
+ }
167
+
168
+ for (let r = 1; r <= count; r++) {
169
+ const c = Math.ceil(count / r)
170
+ const uwgap = userWidth - 12 * (c - 1)
171
+ const uhgap = userHeight - 12 * (r - 1)
172
+ const m = uhgap / height / r
173
+ const size = m * m * width * height * count
174
+ if (c * m * width <= uwgap) fits.push({ r, c, m, size, width, height, userWidth, userHeight })
175
+ }
176
+ const maxSize = fits.reduce((p, c) => (c.size < p ? p : c.size), 0)
177
+ const fit = fits.find((f) => f.size === maxSize)
178
+ if (!fit) return
179
+ const modifier = fit.m ?? 1
180
+ // console.log('FITS', fits, 'modifier', modifier, 'cols',fit?.c, 'rows', fit?.r, 'new size', fit?.size.toFixed(0), 'total space', (userWidth* userHeight).toFixed(0))
181
+ const boxes = Array.from(
182
+ this.valueContainer?.querySelectorAll('.single-value') as NodeListOf<HTMLDivElement>
183
+ )
184
+ this.valueContainer.style.gridTemplateColumns = `repeat(${fit.c}, 1fr)`
185
+
186
+ for (const box of boxes) {
187
+ box.setAttribute(
188
+ 'style',
189
+ `width:${modifier * width}px; height:${modifier * height}px; padding:${modifier * 6}px`
190
+ )
191
+
192
+ const label: string | null = box.getAttribute('label')
193
+ const ds: Dataseries | undefined = this.dataSets.get(label ?? '')
194
+
195
+ const labelText = box.querySelector('.label') as HTMLDivElement
196
+ labelText.setAttribute(
197
+ 'style',
198
+ `font-size: ${26 * modifier}px;
199
+ color: ${ds?.styling?.labelColor || this.themeTitleColor};`
200
+ )
201
+ const numberText = box.querySelector('.current-value') as HTMLDivElement
202
+ numberText.setAttribute(
203
+ 'style',
204
+ `font-size: ${32 * modifier}px;
205
+ color: ${ds?.styling?.valueColor || this.theme?.theme_object?.color?.[0] || this.themeTitleColor};`
206
+ )
207
+
208
+ const unitText = box.querySelector('.unit') as HTMLDivElement
209
+ unitText.setAttribute('style', `font-size: ${26 * modifier}px;`)
210
+ }
211
+
212
+ this.textActive = true
213
+ }
214
+
215
+ async transformData() {
216
+ if (!this.inputData) return
217
+ this.dataSets.forEach((d) => {
218
+ d.label ??= ''
219
+ })
220
+ this.dataSets = new Map()
221
+ this.inputData?.dataseries
222
+ // ?.sort((a, b) => a.order - b.order)
223
+ ?.forEach((ds) => {
224
+ // pivot data
225
+ const distincts = ds.multiChart
226
+ ? ([...new Set(ds.data?.map((d: Data) => d.pivot))].sort() as string[])
227
+ : ['']
228
+
229
+ distincts.forEach((piv) => {
230
+ const prefix = piv ?? ''
231
+ const label = ds.label ?? ''
232
+ const pds: Dataseries = {
233
+ label: prefix + (!!prefix && !!label ? ' - ' : '') + label,
234
+ order: ds.order,
235
+ unit: ds.unit,
236
+ precision: ds.precision,
237
+ advanced: ds.advanced,
238
+ styling: ds.styling,
239
+ multiChart: ds.multiChart,
240
+ value: ds.value,
241
+ data: distincts.length === 1 ? ds.data : ds.data?.filter((d) => d.pivot === piv),
242
+ needleValue: undefined
243
+ }
244
+ this.dataSets.set(pds.label ?? '', pds)
245
+ })
246
+ })
247
+
248
+ // filter latest values and calculate average
249
+ this.dataSets.forEach((ds, label) => {
250
+ ds.advanced ??= {}
251
+ // Fall back to 1 only when the setting is missing or NaN. The
252
+ // inverted guard this replaces reset every VALID number to 1, so
253
+ // averageLatest never averaged anything (widget-gauge has the
254
+ // correct form).
255
+ if (typeof ds.advanced?.averageLatest !== 'number' || isNaN(ds.advanced?.averageLatest))
256
+ ds.advanced.averageLatest = 1
257
+
258
+ if (!ds.multiChart) {
259
+ ds.needleValue =
260
+ ds.value === undefined || ds.value === null ? undefined : Number(ds.value)
261
+ } else {
262
+ const data = ds?.data?.slice(-ds?.advanced?.averageLatest || -1) ?? []
263
+ const values = (data
264
+ ?.map((d) => d.value)
265
+ ?.filter((p) => p !== undefined)
266
+ ?.map(Number) ?? []) as number[]
267
+ ds.needleValue = values.reduce((p, c) => p + c, 0) / values.length
268
+ // Check age of data Latency. The newest row sits at the END of the
269
+ // data array (like the gauge); rows without a parseable tsp opt out.
270
+ const tsp = Date.parse(data?.[data.length - 1]?.tsp ?? '')
271
+ if (!isNaN(tsp)) {
272
+ const now = new Date().getTime()
273
+ if (now - tsp > (ds.advanced?.maxLatency ?? Infinity) * 1000) ds.needleValue = undefined
274
+ }
275
+ }
276
+ })
277
+ }
278
+
279
+ static styles = css`
280
+ :host {
281
+ display: block;
282
+ font-family: sans-serif;
283
+ box-sizing: border-box;
284
+ position: relative;
285
+ margin: auto;
286
+ overflow: hidden;
287
+ container-type: size;
288
+ }
289
+
290
+ .paging:not([active]) {
291
+ display: none !important;
292
+ }
293
+
294
+ .wrapper {
295
+ display: flex;
296
+ flex-direction: column;
297
+ height: 100%;
298
+ width: 100%;
299
+ padding: 2cqh 2cqw;
300
+ box-sizing: border-box;
301
+ }
302
+
303
+ h3 {
304
+ margin: 0;
305
+ max-width: 300px;
306
+ overflow: hidden;
307
+ text-overflow: ellipsis;
308
+ white-space: nowrap;
309
+ }
310
+ p {
311
+ margin: 10px 0 0 0;
312
+ max-width: 300px;
313
+ font-size: 14px;
314
+ line-height: 17px;
315
+ overflow: hidden;
316
+ text-overflow: ellipsis;
317
+ white-space: nowrap;
318
+ }
319
+
320
+ .value-container {
321
+ display: grid;
322
+ line-height: 0.9;
323
+ flex: 1;
324
+ overflow: hidden;
325
+ position: relative;
326
+ gap: 12px;
327
+ }
328
+
329
+ .single-value {
330
+ overflow: hidden;
331
+ position: relative;
332
+ align-items: end;
333
+ padding: 6px;
334
+ box-sizing: border-box;
335
+ }
336
+
337
+ .current-value {
338
+ font-size: 32px;
339
+ font-weight: 600;
340
+ white-space: nowrap;
341
+ }
342
+
343
+ .label,
344
+ .unit {
345
+ font-weight: 300;
346
+ font-size: 26px;
347
+ white-space: nowrap;
348
+ }
349
+
350
+ .sizing-container {
351
+ position: absolute;
352
+ left: 100000px;
353
+ line-height: 0.9;
354
+ overflow: hidden;
355
+ gap: 12px;
356
+ }
357
+
358
+ .no-data {
359
+ font-size: 20px;
360
+ display: flex;
361
+ height: 100%;
362
+ width: 100%;
363
+ text-align: center;
364
+ align-items: center;
365
+ justify-content: center;
366
+ }
367
+ `
368
+
369
+ render() {
370
+ return html`
371
+ <div
372
+ class="wrapper"
373
+ style="background-color: ${this.themeBgColor}; color: ${this.themeTitleColor}"
374
+ >
375
+ <header>
376
+ <h3 class="paging" ?active=${this.inputData?.title}>${this.inputData?.title}</h3>
377
+ <p
378
+ class="paging"
379
+ ?active=${this.inputData?.subTitle}
380
+ style="color: ${this.themeSubtitleColor}"
381
+ >
382
+ ${this.subTitleText()}
383
+ </p>
384
+ </header>
385
+ <div class="sizing-container">
386
+ ${repeat(
387
+ this.dataSets,
388
+ ([label]) => label,
389
+ ([label, ds]) => {
390
+ return html`
391
+ <div class="single-value" label="${label}">
392
+ <div class="label paging" ?active=${this.textActive}>${label}</div>
393
+ <span
394
+ class="current-value paging"
395
+ ?active=${this.textActive}
396
+ style="color: ${ds?.styling?.valueColor ||
397
+ this.theme?.theme_object?.color?.[0] ||
398
+ this.themeTitleColor}"
399
+ >
400
+ ${ds.needleValue === undefined ||
401
+ ds.needleValue === null ||
402
+ isNaN(ds.needleValue)
403
+ ? ''
404
+ : ds.needleValue.toFixed(Math.max(0, ds.precision ?? 0))}
405
+ <span class="unit paging" ?active=${this.textActive}>${ds.unit}</span>
406
+ </span>
407
+ </div>
408
+ `
409
+ }
410
+ )}
411
+ </div>
412
+ <div class="paging no-data" ?active=${!this.dataSets.size}>No Data</div>
413
+ <div class="value-container">
414
+ ${repeat(
415
+ [...this.dataSets.entries()].sort(),
416
+ ([label]) => label,
417
+ ([label, ds]) => {
418
+ return html`
419
+ <div class="single-value" label="${label}">
420
+ <div
421
+ class="label paging"
422
+ style="color: ${ds?.styling?.labelColor || this.themeTitleColor}"
423
+ ?active=${this.textActive}
424
+ >
425
+ ${label}
426
+ </div>
427
+ <span
428
+ class="current-value paging"
429
+ ?active=${this.textActive}
430
+ style="color: ${ds?.styling?.valueColor ||
431
+ this.theme?.theme_object?.color?.[0] ||
432
+ this.themeTitleColor}"
433
+ >
434
+ ${ds.needleValue === undefined ||
435
+ ds.needleValue === null ||
436
+ isNaN(ds.needleValue)
437
+ ? ''
438
+ : ds.needleValue.toFixed(Math.max(0, ds.precision ?? 0))}
439
+ <span class="unit paging" ?active=${this.textActive}>${ds.unit}</span>
440
+ </span>
441
+ </div>
442
+ `
443
+ }
444
+ )}
445
+ </div>
446
+ </div>
447
+ `
448
+ }
449
+ }
package/thumbnail.png ADDED
Binary file
package/thumbnail.webp ADDED
Binary file