@ironflock/widget-value 1.1.36 → 1.1.37
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/dist/widget-value.js +371 -349
- package/dist/widget-value.js.map +1 -1
- package/package.json +1 -1
- package/src/definition-schema.d.ts +2 -2
- package/src/definition-schema.json +2 -2
- package/src/widget-value.ts +117 -76
package/src/widget-value.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { html, css, LitElement,
|
|
1
|
+
import { html, css, LitElement, PropertyValues } from 'lit'
|
|
2
2
|
import { repeat } from 'lit/directives/repeat.js'
|
|
3
3
|
import { customElement, property, query, state } from 'lit/decorators.js'
|
|
4
4
|
import { ValueDisplayConfiguration } from './definition-schema.js'
|
|
@@ -28,6 +28,31 @@ const toDate = (value: unknown): Date | undefined => {
|
|
|
28
28
|
return Number.isFinite(ms) ? new Date(ms) : undefined
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// A finite number, or undefined for null, '', NaN and anything else that is not numeric.
|
|
32
|
+
const toNumber = (value: unknown): number | undefined => {
|
|
33
|
+
if (value === null || value === undefined || value === '') return undefined
|
|
34
|
+
const number = typeof value === 'number' ? value : Number(value)
|
|
35
|
+
return Number.isFinite(number) ? number : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Decimals for toFixed(), which throws outside 0-100.
|
|
39
|
+
const fractionDigits = (precision: unknown): number => {
|
|
40
|
+
const digits = Math.floor(Number(precision))
|
|
41
|
+
return Number.isFinite(digits) ? Math.min(100, Math.max(0, digits)) : 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* When a readout's value goes stale, in epoch milliseconds: the newest row's
|
|
46
|
+
* timestamp plus Maximum Latency. Undefined when it never does, because no
|
|
47
|
+
* latency is set or the newest row (the last one: rows arrive oldest first) has
|
|
48
|
+
* no readable timestamp.
|
|
49
|
+
*/
|
|
50
|
+
const staleAt = (rows: Data[], maxLatency: unknown): number | undefined => {
|
|
51
|
+
const seconds = toNumber(maxLatency)
|
|
52
|
+
const tsp = toDate(rows[rows.length - 1]?.tsp)
|
|
53
|
+
return seconds === undefined || !tsp ? undefined : tsp.getTime() + seconds * 1000
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
@customElement('widget-value-versionplaceholder')
|
|
32
57
|
export class WidgetValue extends LitElement {
|
|
33
58
|
@property({ type: Object })
|
|
@@ -57,41 +82,45 @@ export class WidgetValue extends LitElement {
|
|
|
57
82
|
private lastLargerWidthTime?: number
|
|
58
83
|
private origWidth: number = 0
|
|
59
84
|
private origHeight: number = 0
|
|
85
|
+
// Re-runs transformData when the next readout's data passes Maximum Latency.
|
|
86
|
+
// Studio sends no new inputData while a device is silent, so without it the
|
|
87
|
+
// last value would stay on screen however old it gets.
|
|
88
|
+
private staleTimer?: ReturnType<typeof setTimeout>
|
|
60
89
|
|
|
61
90
|
constructor() {
|
|
62
91
|
super()
|
|
63
92
|
this.resizeObserver = new ResizeObserver(this.applyData.bind(this))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
connectedCallback() {
|
|
96
|
+
super.connectedCallback()
|
|
97
|
+
// Observed on every attach, not once: Studio moves a tile by detaching and
|
|
98
|
+
// re-attaching it, and disconnectedCallback stops observing.
|
|
64
99
|
this.resizeObserver.observe(this)
|
|
100
|
+
// Back after a move: re-check the data's age, which also re-arms the stale timer.
|
|
101
|
+
if (this.hasUpdated) this.transformData()
|
|
65
102
|
}
|
|
66
103
|
|
|
67
104
|
disconnectedCallback() {
|
|
68
105
|
super.disconnectedCallback()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>) {
|
|
75
|
-
this.registerTheme(this.theme)
|
|
106
|
+
this.resizeObserver.disconnect()
|
|
107
|
+
clearTimeout(this.staleTimer)
|
|
108
|
+
this.staleTimer = undefined
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
protected update(changedProperties: PropertyValues): void {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
112
|
+
// Before the render, so the first render already has the theme's colours.
|
|
113
|
+
if (changedProperties.has('theme') || !this.hasUpdated) this.registerTheme(this.theme)
|
|
114
|
+
if (changedProperties.has('inputData')) this.transformData()
|
|
82
115
|
super.update(changedProperties)
|
|
83
116
|
}
|
|
84
117
|
|
|
85
|
-
protected updated(changedProperties:
|
|
86
|
-
if (changedProperties.has('inputData'))
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (changedProperties.has('theme')) {
|
|
92
|
-
this.registerTheme(this.theme)
|
|
93
|
-
}
|
|
94
|
-
|
|
118
|
+
protected updated(changedProperties: PropertyValues) {
|
|
119
|
+
if (changedProperties.has('inputData')) this.sizingSetup()
|
|
120
|
+
// After every render, not only after new data: a render rewrites the text's
|
|
121
|
+
// style attributes, which also carry the font sizes applyData sets, so a
|
|
122
|
+
// theme change used to leave the default sizes until the next data update.
|
|
123
|
+
this.applyData()
|
|
95
124
|
super.updated(changedProperties)
|
|
96
125
|
}
|
|
97
126
|
|
|
@@ -210,68 +239,80 @@ export class WidgetValue extends LitElement {
|
|
|
210
239
|
this.textActive = true
|
|
211
240
|
}
|
|
212
241
|
|
|
213
|
-
|
|
214
|
-
if (!this.inputData) return
|
|
215
|
-
this.dataSets.forEach((d) => {
|
|
216
|
-
d.label ??= ''
|
|
217
|
-
})
|
|
242
|
+
transformData() {
|
|
218
243
|
this.dataSets = new Map()
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
+
// Skip null list slots, and read anything but a list as no list: hand-written
|
|
245
|
+
// board templates have both, and reading a field of null would throw.
|
|
246
|
+
const list = this.inputData?.dataseries
|
|
247
|
+
const dataseries = Array.isArray(list) ? list.filter((ds) => !!ds && typeof ds === 'object') : []
|
|
248
|
+
dataseries.forEach((ds) => {
|
|
249
|
+
const rows = Array.isArray(ds.data)
|
|
250
|
+
? ds.data.filter((d): d is Data => !!d && typeof d === 'object')
|
|
251
|
+
: []
|
|
252
|
+
// pivot data
|
|
253
|
+
const distincts = ds.multiChart ? ([...new Set(rows.map((d) => d.pivot))].sort() as string[]) : ['']
|
|
254
|
+
|
|
255
|
+
distincts.forEach((piv) => {
|
|
256
|
+
const prefix = piv ?? ''
|
|
257
|
+
const label = ds.label ?? ''
|
|
258
|
+
const pds: Dataseries = {
|
|
259
|
+
label: prefix + (!!prefix && !!label ? ' - ' : '') + label,
|
|
260
|
+
order: ds.order,
|
|
261
|
+
unit: ds.unit,
|
|
262
|
+
precision: ds.precision,
|
|
263
|
+
advanced: ds.advanced,
|
|
264
|
+
styling: ds.styling,
|
|
265
|
+
multiChart: ds.multiChart,
|
|
266
|
+
value: ds.value,
|
|
267
|
+
data: distincts.length === 1 ? rows : rows.filter((d) => d.pivot === piv),
|
|
268
|
+
needleValue: undefined
|
|
269
|
+
}
|
|
270
|
+
this.dataSets.set(pds.label ?? '', pds)
|
|
244
271
|
})
|
|
272
|
+
})
|
|
245
273
|
|
|
246
274
|
// filter latest values and calculate average
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
275
|
+
const now = Date.now()
|
|
276
|
+
// the earliest moment a readout that is fresh now goes stale
|
|
277
|
+
let nextStaleAt = Infinity
|
|
278
|
+
this.dataSets.forEach((ds) => {
|
|
279
|
+
// ds.advanced is the host's own object (shared with other tiles): read the
|
|
280
|
+
// settings into locals, never write a default back into it.
|
|
281
|
+
const n = toNumber(ds.advanced?.averageLatest)
|
|
282
|
+
const averageLatest = n !== undefined && n >= 1 ? Math.floor(n) : 1
|
|
255
283
|
|
|
256
284
|
if (!ds.multiChart) {
|
|
257
|
-
ds.needleValue =
|
|
258
|
-
ds.value === undefined || ds.value === null ? undefined : Number(ds.value)
|
|
285
|
+
ds.needleValue = toNumber(ds.value)
|
|
259
286
|
} else {
|
|
260
|
-
const data = ds
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
if (now - tsp > (ds.advanced?.maxLatency ?? Infinity) * 1000) ds.needleValue = undefined
|
|
272
|
-
}
|
|
287
|
+
const data = ds.data?.slice(-averageLatest) ?? []
|
|
288
|
+
// A missing, empty or non-numeric reading is no reading: skip it rather
|
|
289
|
+
// than average it in as 0 (Number(null)) or as NaN.
|
|
290
|
+
const values = data.map((d) => toNumber(d.value)).filter((v): v is number => v !== undefined)
|
|
291
|
+
ds.needleValue = values.length ? values.reduce((p, c) => p + c, 0) / values.length : undefined
|
|
292
|
+
// Hide data older than Maximum Latency. The newest row sits at the END of
|
|
293
|
+
// the data array; rows without a readable tsp opt out, since their age
|
|
294
|
+
// cannot be judged.
|
|
295
|
+
const expiresAt = staleAt(data, ds.advanced?.maxLatency)
|
|
296
|
+
if (expiresAt !== undefined && now > expiresAt) ds.needleValue = undefined
|
|
297
|
+
else if (expiresAt !== undefined) nextStaleAt = Math.min(nextStaleAt, expiresAt)
|
|
273
298
|
}
|
|
274
299
|
})
|
|
300
|
+
this.scheduleStaleCheck(nextStaleAt)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Runs transformData again just after `at` (epoch ms), when the next readout goes stale. */
|
|
304
|
+
private scheduleStaleCheck(at: number) {
|
|
305
|
+
clearTimeout(this.staleTimer)
|
|
306
|
+
this.staleTimer = undefined
|
|
307
|
+
// Nothing goes stale, or detached: connectedCallback re-checks on return.
|
|
308
|
+
if (!Number.isFinite(at) || !this.isConnected) return
|
|
309
|
+
// setTimeout fires at once for delays above 2^31 - 1 ms (about 24.8 days);
|
|
310
|
+
// a capped wait only re-checks and re-arms.
|
|
311
|
+
const delay = Math.min(Math.max(at - Date.now(), 0) + 1, 2 ** 31 - 1)
|
|
312
|
+
this.staleTimer = setTimeout(() => {
|
|
313
|
+
this.staleTimer = undefined
|
|
314
|
+
this.transformData()
|
|
315
|
+
}, delay)
|
|
275
316
|
}
|
|
276
317
|
|
|
277
318
|
static styles = css`
|
|
@@ -396,7 +437,7 @@ export class WidgetValue extends LitElement {
|
|
|
396
437
|
ds.needleValue === null ||
|
|
397
438
|
isNaN(ds.needleValue)
|
|
398
439
|
? ''
|
|
399
|
-
: ds.needleValue.toFixed(
|
|
440
|
+
: ds.needleValue.toFixed(fractionDigits(ds.precision))}
|
|
400
441
|
<span class="unit paging" ?active=${this.textActive}>${ds.unit}</span>
|
|
401
442
|
</span>
|
|
402
443
|
</div>
|
|
@@ -430,7 +471,7 @@ export class WidgetValue extends LitElement {
|
|
|
430
471
|
ds.needleValue === null ||
|
|
431
472
|
isNaN(ds.needleValue)
|
|
432
473
|
? ''
|
|
433
|
-
: ds.needleValue.toFixed(
|
|
474
|
+
: ds.needleValue.toFixed(fractionDigits(ds.precision))}
|
|
434
475
|
<span class="unit paging" ?active=${this.textActive}>${ds.unit}</span>
|
|
435
476
|
</span>
|
|
436
477
|
</div>
|