@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.
@@ -1,4 +1,4 @@
1
- import { html, css, LitElement, PropertyValueMap, PropertyValues } from 'lit'
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
- if (this.resizeObserver) {
70
- this.resizeObserver.disconnect()
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
- if (changedProperties.has('inputData')) {
80
- this.transformData()
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: Map<string, any>) {
86
- if (changedProperties.has('inputData')) {
87
- this.sizingSetup()
88
- this.applyData()
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
- async transformData() {
214
- if (!this.inputData) return
215
- this.dataSets.forEach((d) => {
216
- d.label ??= ''
217
- })
242
+ transformData() {
218
243
  this.dataSets = new Map()
219
- this.inputData?.dataseries
220
- // ?.sort((a, b) => a.order - b.order)
221
- ?.forEach((ds) => {
222
- // pivot data
223
- const distincts = ds.multiChart
224
- ? ([...new Set(ds.data?.map((d: Data) => d.pivot))].sort() as string[])
225
- : ['']
226
-
227
- distincts.forEach((piv) => {
228
- const prefix = piv ?? ''
229
- const label = ds.label ?? ''
230
- const pds: Dataseries = {
231
- label: prefix + (!!prefix && !!label ? ' - ' : '') + label,
232
- order: ds.order,
233
- unit: ds.unit,
234
- precision: ds.precision,
235
- advanced: ds.advanced,
236
- styling: ds.styling,
237
- multiChart: ds.multiChart,
238
- value: ds.value,
239
- data: distincts.length === 1 ? ds.data : ds.data?.filter((d) => d.pivot === piv),
240
- needleValue: undefined
241
- }
242
- this.dataSets.set(pds.label ?? '', pds)
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
- this.dataSets.forEach((ds, label) => {
248
- ds.advanced ??= {}
249
- // Fall back to 1 only when the setting is missing or NaN. The
250
- // inverted guard this replaces reset every VALID number to 1, so
251
- // averageLatest never averaged anything (widget-gauge has the
252
- // correct form).
253
- if (typeof ds.advanced?.averageLatest !== 'number' || isNaN(ds.advanced?.averageLatest))
254
- ds.advanced.averageLatest = 1
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?.data?.slice(-ds?.advanced?.averageLatest || -1) ?? []
261
- const values = (data
262
- ?.map((d) => d.value)
263
- ?.filter((p) => p !== undefined)
264
- ?.map(Number) ?? []) as number[]
265
- ds.needleValue = values.reduce((p, c) => p + c, 0) / values.length
266
- // Check age of data Latency. The newest row sits at the END of the
267
- // data array (like the gauge); rows without a parseable tsp opt out.
268
- const tsp = Date.parse(data?.[data.length - 1]?.tsp ?? '')
269
- if (!isNaN(tsp)) {
270
- const now = new Date().getTime()
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(Math.max(0, ds.precision ?? 0))}
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(Math.max(0, ds.precision ?? 0))}
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>