@ironflock/widget-value 0.0.0-stage → 1.1.35

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,444 @@
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 themeTitleColor?: string
46
+ @state() private themeSubtitleColor?: string
47
+
48
+ @query('.value-container')
49
+ valueContainer?: HTMLDivElement
50
+
51
+ @query('.sizing-container')
52
+ sizingContainer?: HTMLDivElement
53
+
54
+ version: string = 'versionplaceholder'
55
+
56
+ private resizeObserver: ResizeObserver
57
+ private lastLargerWidthTime?: number
58
+ private origWidth: number = 0
59
+ private origHeight: number = 0
60
+
61
+ constructor() {
62
+ super()
63
+ this.resizeObserver = new ResizeObserver(this.applyData.bind(this))
64
+ this.resizeObserver.observe(this)
65
+ }
66
+
67
+ disconnectedCallback() {
68
+ 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)
76
+ }
77
+
78
+ protected update(changedProperties: PropertyValues): void {
79
+ if (changedProperties.has('inputData')) {
80
+ this.transformData()
81
+ }
82
+ super.update(changedProperties)
83
+ }
84
+
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
+
95
+ super.updated(changedProperties)
96
+ }
97
+
98
+ registerTheme(theme?: Theme) {
99
+ // The host's --re-tile-background-color / --re-text-color are not read
100
+ // through getComputedStyle here. That snapshots them, and this only runs
101
+ // on a `theme` property change, so a board style edit — which changes the
102
+ // custom property and nothing else — left the tile painting the previous
103
+ // colour until it was reloaded. What these fields hold is a var() chain
104
+ // with the theme's own colour as the fallback, so the host property keeps
105
+ // winning over the theme, and the browser resolves it on every repaint
106
+ // without the widget being told anything.
107
+ const themeBg = this.theme?.theme_object?.backgroundColor
108
+ const themeText = this.theme?.theme_object?.title?.textStyle?.color
109
+ const themeSubtext = this.theme?.theme_object?.title?.subtextStyle?.color || themeText
110
+
111
+ this.themeTitleColor = `var(--re-text-color, ${themeText ?? 'inherit'})`
112
+ this.themeSubtitleColor = `var(--re-text-color, ${themeSubtext ?? 'inherit'})`
113
+ }
114
+
115
+ subTitleText() {
116
+ const subTitle = this.inputData?.subTitle
117
+ if (this.inputData?.subTitleType !== 'timestamp') return subTitle
118
+ const date = toDate(subTitle)
119
+ return date ? localDateTime.format(date) : subTitle
120
+ }
121
+
122
+ sizingSetup() {
123
+ const boxes = Array.from(
124
+ this.sizingContainer?.querySelectorAll('.single-value') as NodeListOf<HTMLDivElement>
125
+ )
126
+
127
+ let width = 0
128
+ let height = 0
129
+ for (const box of boxes) {
130
+ const dims = box.getBoundingClientRect()
131
+ width = Math.max(dims.width, width)
132
+ height = Math.max(dims.height, height)
133
+ }
134
+
135
+ if (width < this.origWidth) {
136
+ if (!this.lastLargerWidthTime || Date.now() - this.lastLargerWidthTime > 5000) {
137
+ this.origWidth = width
138
+ }
139
+ } else {
140
+ this.origWidth = width
141
+ this.lastLargerWidthTime = Date.now()
142
+ }
143
+ this.origHeight = height
144
+ }
145
+
146
+ applyData() {
147
+ if (!this.valueContainer) return
148
+ const userWidth = this.valueContainer.getBoundingClientRect().width
149
+ const userHeight = this.valueContainer.getBoundingClientRect().height
150
+ const count = this.dataSets.size
151
+
152
+ const width = this.origWidth
153
+ const height = this.origHeight
154
+ if (!userWidth || !userHeight || !width || !height) return
155
+
156
+ const fits = []
157
+ for (let c = 1; c <= count; c++) {
158
+ const r = Math.ceil(count / c)
159
+ const uwgap = userWidth - 12 * (c - 1)
160
+ const uhgap = userHeight - 12 * (r - 1)
161
+ const m = uwgap / width / c
162
+ const size = m * m * width * height * count
163
+ if (r * m * height <= uhgap) fits.push({ r, c, m, size, width, height, userWidth, userHeight })
164
+ }
165
+
166
+ for (let r = 1; r <= count; r++) {
167
+ const c = Math.ceil(count / r)
168
+ const uwgap = userWidth - 12 * (c - 1)
169
+ const uhgap = userHeight - 12 * (r - 1)
170
+ const m = uhgap / height / r
171
+ const size = m * m * width * height * count
172
+ if (c * m * width <= uwgap) fits.push({ r, c, m, size, width, height, userWidth, userHeight })
173
+ }
174
+ const maxSize = fits.reduce((p, c) => (c.size < p ? p : c.size), 0)
175
+ const fit = fits.find((f) => f.size === maxSize)
176
+ if (!fit) return
177
+ const modifier = fit.m ?? 1
178
+ // console.log('FITS', fits, 'modifier', modifier, 'cols',fit?.c, 'rows', fit?.r, 'new size', fit?.size.toFixed(0), 'total space', (userWidth* userHeight).toFixed(0))
179
+ const boxes = Array.from(
180
+ this.valueContainer?.querySelectorAll('.single-value') as NodeListOf<HTMLDivElement>
181
+ )
182
+ this.valueContainer.style.gridTemplateColumns = `repeat(${fit.c}, 1fr)`
183
+
184
+ for (const box of boxes) {
185
+ box.setAttribute(
186
+ 'style',
187
+ `width:${modifier * width}px; height:${modifier * height}px; padding:${modifier * 6}px`
188
+ )
189
+
190
+ const label: string | null = box.getAttribute('label')
191
+ const ds: Dataseries | undefined = this.dataSets.get(label ?? '')
192
+
193
+ const labelText = box.querySelector('.label') as HTMLDivElement
194
+ labelText.setAttribute(
195
+ 'style',
196
+ `font-size: ${26 * modifier}px;
197
+ color: ${ds?.styling?.labelColor || this.themeTitleColor};`
198
+ )
199
+ const numberText = box.querySelector('.current-value') as HTMLDivElement
200
+ numberText.setAttribute(
201
+ 'style',
202
+ `font-size: ${32 * modifier}px;
203
+ color: ${ds?.styling?.valueColor || this.theme?.theme_object?.color?.[0] || this.themeTitleColor};`
204
+ )
205
+
206
+ const unitText = box.querySelector('.unit') as HTMLDivElement
207
+ unitText.setAttribute('style', `font-size: ${26 * modifier}px;`)
208
+ }
209
+
210
+ this.textActive = true
211
+ }
212
+
213
+ async transformData() {
214
+ if (!this.inputData) return
215
+ this.dataSets.forEach((d) => {
216
+ d.label ??= ''
217
+ })
218
+ 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
+ })
245
+
246
+ // 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
255
+
256
+ if (!ds.multiChart) {
257
+ ds.needleValue =
258
+ ds.value === undefined || ds.value === null ? undefined : Number(ds.value)
259
+ } 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
+ }
273
+ }
274
+ })
275
+ }
276
+
277
+ static styles = css`
278
+ :host {
279
+ display: block;
280
+ font-family: sans-serif;
281
+ box-sizing: border-box;
282
+ position: relative;
283
+ margin: auto;
284
+ overflow: hidden;
285
+ container-type: size;
286
+ }
287
+
288
+ .paging:not([active]) {
289
+ display: none !important;
290
+ }
291
+
292
+ .wrapper {
293
+ display: flex;
294
+ flex-direction: column;
295
+ height: 100%;
296
+ width: 100%;
297
+ padding: 2cqh 2cqw;
298
+ box-sizing: border-box;
299
+ }
300
+
301
+ h3 {
302
+ margin: 0;
303
+ max-width: 300px;
304
+ overflow: hidden;
305
+ text-overflow: ellipsis;
306
+ white-space: nowrap;
307
+ }
308
+ p {
309
+ margin: 10px 0 0 0;
310
+ max-width: 300px;
311
+ font-size: 14px;
312
+ line-height: 17px;
313
+ overflow: hidden;
314
+ text-overflow: ellipsis;
315
+ white-space: nowrap;
316
+ }
317
+
318
+ .value-container {
319
+ display: grid;
320
+ line-height: 0.9;
321
+ flex: 1;
322
+ overflow: hidden;
323
+ position: relative;
324
+ gap: 12px;
325
+ }
326
+
327
+ .single-value {
328
+ overflow: hidden;
329
+ position: relative;
330
+ align-items: end;
331
+ padding: 6px;
332
+ box-sizing: border-box;
333
+ }
334
+
335
+ .current-value {
336
+ font-size: 32px;
337
+ font-weight: 600;
338
+ white-space: nowrap;
339
+ }
340
+
341
+ .label,
342
+ .unit {
343
+ font-weight: 300;
344
+ font-size: 26px;
345
+ white-space: nowrap;
346
+ }
347
+
348
+ .sizing-container {
349
+ position: absolute;
350
+ left: 100000px;
351
+ line-height: 0.9;
352
+ overflow: hidden;
353
+ gap: 12px;
354
+ }
355
+
356
+ .no-data {
357
+ font-size: 20px;
358
+ display: flex;
359
+ height: 100%;
360
+ width: 100%;
361
+ text-align: center;
362
+ align-items: center;
363
+ justify-content: center;
364
+ }
365
+ `
366
+
367
+ render() {
368
+ return html`
369
+ <div class="wrapper" style="color: ${this.themeTitleColor}">
370
+ <header>
371
+ <h3 class="paging" ?active=${this.inputData?.title}>${this.inputData?.title}</h3>
372
+ <p
373
+ class="paging"
374
+ ?active=${this.inputData?.subTitle}
375
+ style="color: ${this.themeSubtitleColor}"
376
+ >
377
+ ${this.subTitleText()}
378
+ </p>
379
+ </header>
380
+ <div class="sizing-container">
381
+ ${repeat(
382
+ this.dataSets,
383
+ ([label]) => label,
384
+ ([label, ds]) => {
385
+ return html`
386
+ <div class="single-value" label="${label}">
387
+ <div class="label paging" ?active=${this.textActive}>${label}</div>
388
+ <span
389
+ class="current-value paging"
390
+ ?active=${this.textActive}
391
+ style="color: ${ds?.styling?.valueColor ||
392
+ this.theme?.theme_object?.color?.[0] ||
393
+ this.themeTitleColor}"
394
+ >
395
+ ${ds.needleValue === undefined ||
396
+ ds.needleValue === null ||
397
+ isNaN(ds.needleValue)
398
+ ? ''
399
+ : ds.needleValue.toFixed(Math.max(0, ds.precision ?? 0))}
400
+ <span class="unit paging" ?active=${this.textActive}>${ds.unit}</span>
401
+ </span>
402
+ </div>
403
+ `
404
+ }
405
+ )}
406
+ </div>
407
+ <div class="paging no-data" ?active=${!this.dataSets.size}>No Data</div>
408
+ <div class="value-container">
409
+ ${repeat(
410
+ [...this.dataSets.entries()].sort(),
411
+ ([label]) => label,
412
+ ([label, ds]) => {
413
+ return html`
414
+ <div class="single-value" label="${label}">
415
+ <div
416
+ class="label paging"
417
+ style="color: ${ds?.styling?.labelColor || this.themeTitleColor}"
418
+ ?active=${this.textActive}
419
+ >
420
+ ${label}
421
+ </div>
422
+ <span
423
+ class="current-value paging"
424
+ ?active=${this.textActive}
425
+ style="color: ${ds?.styling?.valueColor ||
426
+ this.theme?.theme_object?.color?.[0] ||
427
+ this.themeTitleColor}"
428
+ >
429
+ ${ds.needleValue === undefined ||
430
+ ds.needleValue === null ||
431
+ isNaN(ds.needleValue)
432
+ ? ''
433
+ : ds.needleValue.toFixed(Math.max(0, ds.precision ?? 0))}
434
+ <span class="unit paging" ?active=${this.textActive}>${ds.unit}</span>
435
+ </span>
436
+ </div>
437
+ `
438
+ }
439
+ )}
440
+ </div>
441
+ </div>
442
+ `
443
+ }
444
+ }
package/thumbnail.png ADDED
Binary file
package/thumbnail.webp ADDED
Binary file