@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.
- package/README.md +49 -2
- package/dist/widget-value.js +943 -0
- package/dist/widget-value.js.map +1 -0
- package/package.json +63 -5
- package/src/default-data.json +15 -0
- package/src/definition-schema.d.ts +121 -0
- package/src/definition-schema.json +172 -0
- package/src/widget-value.ts +444 -0
- package/thumbnail.png +0 -0
- package/thumbnail.webp +0 -0
|
@@ -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
|