@record-evolution/widget-overlay 1.0.1
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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/linear-progress.js +73 -0
- package/dist/linear-progress.js.map +1 -0
- package/dist/property-v0slTy4i.js +760 -0
- package/dist/property-v0slTy4i.js.map +1 -0
- package/dist/src/linear-progress.d.ts +7 -0
- package/dist/src/widget-overlay.d.ts +35 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/widget-overlay.js +650 -0
- package/dist/widget-overlay.js.map +1 -0
- package/package.json +51 -0
- package/src/default-data.json +88 -0
- package/src/definition-schema.d.ts +67 -0
- package/src/definition-schema.json +200 -0
- package/src/linear-progress.ts +59 -0
- package/src/widget-overlay.ts +430 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { html, css, LitElement, PropertyValues, nothing } from 'lit'
|
|
2
|
+
import { property, query, state } from 'lit/decorators.js'
|
|
3
|
+
import { styleMap } from 'lit/directives/style-map.js'
|
|
4
|
+
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
|
5
|
+
import {
|
|
6
|
+
HtmlOverlay,
|
|
7
|
+
InputData,
|
|
8
|
+
Modifier,
|
|
9
|
+
Overlay,
|
|
10
|
+
OverlayItem,
|
|
11
|
+
ProgressOverlay,
|
|
12
|
+
SwitchOverlay,
|
|
13
|
+
TextOverlay
|
|
14
|
+
} from './definition-schema.js'
|
|
15
|
+
import { repeat } from 'lit/directives/repeat.js'
|
|
16
|
+
|
|
17
|
+
type Theme = {
|
|
18
|
+
theme_name: string
|
|
19
|
+
theme_object: any
|
|
20
|
+
}
|
|
21
|
+
export class WidgetImage extends LitElement {
|
|
22
|
+
@property({ type: Object })
|
|
23
|
+
inputData?: InputData
|
|
24
|
+
|
|
25
|
+
@property({ type: Object })
|
|
26
|
+
theme?: Theme
|
|
27
|
+
|
|
28
|
+
@state() private themeBgColor?: string
|
|
29
|
+
@state() private themeTitleColor?: string
|
|
30
|
+
@state() private themeSubtitleColor?: string
|
|
31
|
+
@state() private previewUrl?: string
|
|
32
|
+
@state() private inlineSvg?: string
|
|
33
|
+
|
|
34
|
+
@state() private modifier?: Modifier
|
|
35
|
+
|
|
36
|
+
constructor() {
|
|
37
|
+
super()
|
|
38
|
+
this.getModifier = this.getModifier.bind(this)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@query('#base-layer')
|
|
42
|
+
private baseLayer!: any
|
|
43
|
+
|
|
44
|
+
private renderMap = {
|
|
45
|
+
progress: this.renderProgress.bind(this),
|
|
46
|
+
text: this.renderText.bind(this),
|
|
47
|
+
switch: this.renderSwitch.bind(this),
|
|
48
|
+
html: this.renderHtml.bind(this)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
version: string = 'versionplaceholder'
|
|
52
|
+
|
|
53
|
+
update(changedProperties: Map<string, any>) {
|
|
54
|
+
if (changedProperties.has('inputData')) {
|
|
55
|
+
this.transform()
|
|
56
|
+
if (!this.modifier) this.getModifier()
|
|
57
|
+
}
|
|
58
|
+
if (changedProperties.has('theme')) {
|
|
59
|
+
this.registerTheme(this.theme)
|
|
60
|
+
}
|
|
61
|
+
super.update(changedProperties)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected firstUpdated(_changedProperties: PropertyValues): void {
|
|
65
|
+
this.registerTheme(this.theme)
|
|
66
|
+
|
|
67
|
+
const ro = new ResizeObserver(this.getModifier)
|
|
68
|
+
ro.observe(this)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
registerTheme(theme?: Theme) {
|
|
72
|
+
const cssTextColor = getComputedStyle(this).getPropertyValue('--re-text-color').trim()
|
|
73
|
+
const cssBgColor = getComputedStyle(this).getPropertyValue('--re-tile-background-color').trim()
|
|
74
|
+
this.themeBgColor = cssBgColor || this.theme?.theme_object?.backgroundColor
|
|
75
|
+
this.themeTitleColor = cssTextColor || this.theme?.theme_object?.title?.textStyle?.color
|
|
76
|
+
this.themeSubtitleColor =
|
|
77
|
+
cssTextColor || this.theme?.theme_object?.title?.subtextStyle?.color || this.themeTitleColor
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private handleFileChange(ev: Event) {
|
|
81
|
+
const input = ev.target as HTMLInputElement
|
|
82
|
+
const file = input.files?.[0]
|
|
83
|
+
if (!file) return
|
|
84
|
+
// Revoke previous object URL
|
|
85
|
+
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
|
86
|
+
this.previewUrl = URL.createObjectURL(file)
|
|
87
|
+
this.dispatchEvent(
|
|
88
|
+
new CustomEvent('overlay-file-selected', {
|
|
89
|
+
detail: { file, name: file.name, size: file.size, type: file.type, url: this.previewUrl },
|
|
90
|
+
bubbles: true,
|
|
91
|
+
composed: true
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
transform() {
|
|
97
|
+
this.inlineSvg = undefined
|
|
98
|
+
if (!this.inputData?.image) {
|
|
99
|
+
this.previewUrl = undefined
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const raw = this.inputData.image.trim()
|
|
103
|
+
|
|
104
|
+
// Dynamically load progress bar element if needed
|
|
105
|
+
if (this.inputData?.overlays?.some((o) => o.layerType === 'progress')) {
|
|
106
|
+
import('./linear-progress.js')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Inline SVG markup (optionally starting with an XML declaration or other preamble)
|
|
110
|
+
const trimmed = raw.replace(/^\uFEFF/, '').trimStart()
|
|
111
|
+
|
|
112
|
+
// Find the actual <svg ...>...</svg> block even if preceded by <?xml ...?> or <!DOCTYPE ...>
|
|
113
|
+
const svgBlockMatch = trimmed.match(/<svg[\s\S]*?<\/svg>/i)
|
|
114
|
+
|
|
115
|
+
if (svgBlockMatch) {
|
|
116
|
+
this.inlineSvg = svgBlockMatch[0].trim()
|
|
117
|
+
this.previewUrl = undefined
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Full data URL (already encoded)
|
|
122
|
+
if (/^data:image\/svg\+xml[,;]/.test(raw)) {
|
|
123
|
+
// It's an SVG data URL; we could theoretically decode, but safer to keep as src
|
|
124
|
+
this.previewUrl = raw
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (/^data:image\/[a-zA-Z.+-]+;base64,/.test(raw)) {
|
|
128
|
+
this.previewUrl = raw
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Bare base64 string (heuristic)
|
|
133
|
+
if (/^[A-Za-z0-9+/]+=*$/.test(raw) && raw.length > 100) {
|
|
134
|
+
this.previewUrl = `data:image/*;base64,${raw}`
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Absolute URL
|
|
139
|
+
try {
|
|
140
|
+
new URL(raw)
|
|
141
|
+
this.previewUrl = raw
|
|
142
|
+
return
|
|
143
|
+
} catch {
|
|
144
|
+
// Relative path
|
|
145
|
+
if (/^([./]|\/)/.test(raw)) {
|
|
146
|
+
this.previewUrl = raw
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Fallback
|
|
152
|
+
this.previewUrl = undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async getModifier() {
|
|
156
|
+
if (!this.baseLayer) return
|
|
157
|
+
const rect = this.baseLayer.getBoundingClientRect()
|
|
158
|
+
if (!rect) return
|
|
159
|
+
|
|
160
|
+
const containerRect = this.baseLayer.parentElement?.getBoundingClientRect()
|
|
161
|
+
if (!containerRect) return
|
|
162
|
+
|
|
163
|
+
let intrinsicWidth: number | undefined
|
|
164
|
+
let intrinsicHeight: number | undefined
|
|
165
|
+
|
|
166
|
+
if (this.baseLayer instanceof HTMLImageElement) {
|
|
167
|
+
// For <img>
|
|
168
|
+
intrinsicWidth = this.baseLayer.naturalWidth
|
|
169
|
+
intrinsicHeight = this.baseLayer.naturalHeight
|
|
170
|
+
} else {
|
|
171
|
+
const innerSvg = this.baseLayer.querySelector('svg')
|
|
172
|
+
|
|
173
|
+
if (innerSvg) {
|
|
174
|
+
// For <svg>
|
|
175
|
+
const viewBox = innerSvg.viewBox.baseVal
|
|
176
|
+
console.log(viewBox)
|
|
177
|
+
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
|
|
178
|
+
intrinsicWidth = viewBox.width
|
|
179
|
+
intrinsicHeight = viewBox.height
|
|
180
|
+
} else {
|
|
181
|
+
// fallback to width/height attributes if set
|
|
182
|
+
const widthAttr = this.baseLayer.getAttribute('width')
|
|
183
|
+
const heightAttr = this.baseLayer.getAttribute('height')
|
|
184
|
+
if (widthAttr && heightAttr) {
|
|
185
|
+
intrinsicWidth = parseFloat(widthAttr)
|
|
186
|
+
intrinsicHeight = parseFloat(heightAttr)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!intrinsicWidth || !intrinsicHeight) return
|
|
193
|
+
|
|
194
|
+
// Actual visible area of the image/svg
|
|
195
|
+
const scaleX = rect.width / intrinsicWidth
|
|
196
|
+
const scaleY = rect.height / intrinsicHeight
|
|
197
|
+
const scale = Math.min(scaleX, scaleY)
|
|
198
|
+
|
|
199
|
+
const visibleWidth = intrinsicWidth * scale
|
|
200
|
+
const visibleHeight = intrinsicHeight * scale
|
|
201
|
+
|
|
202
|
+
const xOffset = (containerRect.width - visibleWidth) / 2
|
|
203
|
+
const yOffset = (containerRect.height - visibleHeight) / 2
|
|
204
|
+
|
|
205
|
+
this.modifier = { scaler: scale, xOffset, yOffset, visibleWidth, visibleHeight }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private getOverlayItemPosition(relX: number, relY: number, modifier: Modifier) {
|
|
209
|
+
return {
|
|
210
|
+
left: modifier.xOffset + relX * modifier.visibleWidth,
|
|
211
|
+
top: modifier.yOffset + relY * modifier.visibleHeight
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
renderText(_overlay: Overlay, item: OverlayItem, modifier: Modifier) {
|
|
216
|
+
const overlay = _overlay as TextOverlay
|
|
217
|
+
const pos = this.getOverlayItemPosition(item.relXPos, item.relYPos, modifier)
|
|
218
|
+
if (!pos) return nothing
|
|
219
|
+
|
|
220
|
+
const precision = overlay.textStyle?.precision ?? 1
|
|
221
|
+
let value = item.data
|
|
222
|
+
const numericValue = Number(value)
|
|
223
|
+
let color = '#333'
|
|
224
|
+
if (!Number.isNaN(numericValue)) {
|
|
225
|
+
if (typeof precision === 'number' && precision >= 0) {
|
|
226
|
+
value = numericValue.toFixed(precision)
|
|
227
|
+
const sectionIndex =
|
|
228
|
+
overlay.sections?.sectionLimits.findIndex((limit) => numericValue <= limit) ?? -2
|
|
229
|
+
color = (sectionIndex >= 1 ? overlay.sections?.colors[sectionIndex - 1] : '#333') ?? '#333'
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const styles = {
|
|
234
|
+
'font-size': (overlay.textStyle.fontSize ?? 14) * modifier.scaler + 'px',
|
|
235
|
+
color,
|
|
236
|
+
'background-color': overlay.textStyle.backgroundColor,
|
|
237
|
+
'font-weight': overlay.textStyle.fontWeight,
|
|
238
|
+
left: `${pos.left}px`,
|
|
239
|
+
top: `${pos.top}px`,
|
|
240
|
+
'border-radius': 10 * modifier.scaler + 'px',
|
|
241
|
+
'transform-origin': '50% 50%',
|
|
242
|
+
transform: `translate(-50%, -50%)`,
|
|
243
|
+
padding: `${6 * modifier.scaler}px ${12 * modifier.scaler}px`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return html` <div class="overlay-item" style=${styleMap(styles)}>${item.title} ${value}</div> `
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
renderProgress(_overlay: Overlay, item: OverlayItem, modifier: Modifier) {
|
|
250
|
+
const overlay = _overlay as ProgressOverlay
|
|
251
|
+
const pos = this.getOverlayItemPosition(item.relXPos, item.relYPos, modifier)
|
|
252
|
+
if (!pos) return nothing
|
|
253
|
+
|
|
254
|
+
const sectionIndex =
|
|
255
|
+
overlay.sections?.sectionLimits.findIndex((limit) => Number(item.data) <= limit) ?? -2
|
|
256
|
+
const sectionColor = sectionIndex >= 1 ? overlay.sections?.colors[sectionIndex - 1] : undefined
|
|
257
|
+
const styles = {
|
|
258
|
+
'--progress-color': sectionColor ?? '#333',
|
|
259
|
+
'--progress-width': (overlay.progressStyle?.width ?? 10) * modifier.scaler + 'px',
|
|
260
|
+
'--progress-height': (overlay.progressStyle?.height ?? 100) * modifier.scaler + 'px',
|
|
261
|
+
'--progress-background': overlay.progressStyle?.backgroundColor ?? '#fff',
|
|
262
|
+
'border-radius': 10 * modifier.scaler + 'px',
|
|
263
|
+
'transform-origin': '50% 50%',
|
|
264
|
+
transform: `translate(-50%, -50%) rotate(${overlay.progressStyle?.rotate ?? 0}deg)`,
|
|
265
|
+
left: `${pos.left}px`,
|
|
266
|
+
top: `${pos.top}px`
|
|
267
|
+
}
|
|
268
|
+
// Derive percentage based on item.data within overlay.sections min/max.
|
|
269
|
+
// Supports several possible property names; falls back to sectionLimits array.
|
|
270
|
+
const rawValue = Number(item.data)
|
|
271
|
+
const sections: any = overlay.sections ?? {}
|
|
272
|
+
const min = Math.min(...sections.sectionLimits.map(Number))
|
|
273
|
+
const max = Math.max(...sections.sectionLimits.map(Number))
|
|
274
|
+
let percent = max === min ? 0 : (rawValue - min) / (max - min)
|
|
275
|
+
percent = Math.min(1, Math.max(0, percent))
|
|
276
|
+
// percent now holds the normalized 0-100 percentage for rawValue
|
|
277
|
+
return html` <linear-progress .value=${percent} style=${styleMap(styles)}></linear-progress> `
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
renderHtml(_overlay: Overlay, item: OverlayItem, modifier: Modifier) {
|
|
281
|
+
return html` <div>${item.title}: ${item.data}</div> `
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
renderSwitch(_overlay: Overlay, item: OverlayItem, modifier: Modifier) {
|
|
285
|
+
return html` <div>${item.title}: ${item.data}</div> `
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
renderLayer(overlay: Overlay) {
|
|
289
|
+
const modifier = this.modifier
|
|
290
|
+
if (!modifier) return nothing
|
|
291
|
+
return html` ${repeat(
|
|
292
|
+
overlay.items ?? [],
|
|
293
|
+
(item) => item.title,
|
|
294
|
+
(item) => {
|
|
295
|
+
return html` ${this.renderMap[overlay.layerType](overlay, item, modifier)} `
|
|
296
|
+
}
|
|
297
|
+
)}`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
static styles = css`
|
|
301
|
+
:host {
|
|
302
|
+
display: block;
|
|
303
|
+
font-family: sans-serif;
|
|
304
|
+
box-sizing: border-box;
|
|
305
|
+
margin: auto;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.paging:not([active]) {
|
|
309
|
+
display: none !important;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.wrapper {
|
|
313
|
+
display: flex;
|
|
314
|
+
flex-direction: column;
|
|
315
|
+
height: 100%;
|
|
316
|
+
width: 100%;
|
|
317
|
+
padding: 16px;
|
|
318
|
+
box-sizing: border-box;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.svg-wrapper {
|
|
322
|
+
width: 100%;
|
|
323
|
+
height: 100%;
|
|
324
|
+
overflow: hidden;
|
|
325
|
+
position: relative;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.svg-wrapper svg {
|
|
329
|
+
width: 100%;
|
|
330
|
+
height: 100%;
|
|
331
|
+
display: block;
|
|
332
|
+
/* preserveAspectRatio inside SVG handles "contain"-like scaling */
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
h3 {
|
|
336
|
+
margin: 0;
|
|
337
|
+
max-width: 300px;
|
|
338
|
+
overflow: hidden;
|
|
339
|
+
text-overflow: ellipsis;
|
|
340
|
+
white-space: nowrap;
|
|
341
|
+
}
|
|
342
|
+
p {
|
|
343
|
+
margin: 10px 0 0 0;
|
|
344
|
+
max-width: 300px;
|
|
345
|
+
font-size: 14px;
|
|
346
|
+
line-height: 17px;
|
|
347
|
+
overflow: hidden;
|
|
348
|
+
text-overflow: ellipsis;
|
|
349
|
+
white-space: nowrap;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.img-container {
|
|
353
|
+
flex: 1;
|
|
354
|
+
box-sizing: border-box;
|
|
355
|
+
overflow: hidden;
|
|
356
|
+
position: relative;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
img {
|
|
360
|
+
width: 100%; /* Set the width of the container */
|
|
361
|
+
height: 100%;
|
|
362
|
+
object-fit: contain;
|
|
363
|
+
}
|
|
364
|
+
.no-data {
|
|
365
|
+
font-size: 20px;
|
|
366
|
+
display: flex;
|
|
367
|
+
height: 100%;
|
|
368
|
+
width: 100%;
|
|
369
|
+
text-align: center;
|
|
370
|
+
align-items: center;
|
|
371
|
+
justify-content: center;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.overlay {
|
|
375
|
+
position: absolute;
|
|
376
|
+
top: 0;
|
|
377
|
+
left: 0;
|
|
378
|
+
right: 0;
|
|
379
|
+
bottom: 0;
|
|
380
|
+
pointer-events: none;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.overlay-item {
|
|
384
|
+
position: absolute;
|
|
385
|
+
pointer-events: auto;
|
|
386
|
+
}
|
|
387
|
+
`
|
|
388
|
+
|
|
389
|
+
render() {
|
|
390
|
+
const hasImage = !!this.inlineSvg || !!this.previewUrl
|
|
391
|
+
return html`
|
|
392
|
+
<div class="wrapper" style="background-color: ${this.themeBgColor}">
|
|
393
|
+
<h3 class="paging" ?active=${this.inputData?.title} style="color: ${this.themeTitleColor}">
|
|
394
|
+
${this.inputData?.title}
|
|
395
|
+
</h3>
|
|
396
|
+
<p
|
|
397
|
+
class="paging"
|
|
398
|
+
?active=${this.inputData?.subTitle}
|
|
399
|
+
style="color: ${this.themeSubtitleColor}"
|
|
400
|
+
>
|
|
401
|
+
${this.inputData?.subTitle}
|
|
402
|
+
</p>
|
|
403
|
+
|
|
404
|
+
<div class="paging no-data" ?active=${!hasImage}>No Image</div>
|
|
405
|
+
<div class="img-container paging" ?active="${hasImage}">
|
|
406
|
+
${this.inlineSvg
|
|
407
|
+
? html`<div id="base-layer" class="svg-wrapper">${unsafeSVG(this.inlineSvg)}</div>`
|
|
408
|
+
: this.previewUrl
|
|
409
|
+
? html`<img
|
|
410
|
+
id="base-layer"
|
|
411
|
+
@load="${this.getModifier}"
|
|
412
|
+
src="${this.previewUrl}"
|
|
413
|
+
alt="Image Widget"
|
|
414
|
+
/>`
|
|
415
|
+
: ''}
|
|
416
|
+
${repeat(
|
|
417
|
+
this.inputData?.overlays ?? [],
|
|
418
|
+
(o) => o.layerName,
|
|
419
|
+
(o) => html`
|
|
420
|
+
<div class="overlay" type="${o.layerType}" name="${o.layerName}">
|
|
421
|
+
${this.renderLayer(o)}
|
|
422
|
+
</div>
|
|
423
|
+
`
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
`
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
window.customElements.define('widget-overlay-versionplaceholder', WidgetImage)
|