@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.
@@ -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)