import { CustomHTMLElement } from './lib/custom_html_element.mjs' const WIDGET_SPACE = 0 const WIDGET_LABEL = 1 const WIDGET_ONOFF = 2 export class SmonDashboard extends CustomHTMLElement {// {{{ static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
Cell operations
` }// }}} constructor() {// {{{ super(true) const script = this.querySelector('script') this.data = JSON.parse(script.textContent) this.widgets = new Map() // keyed by datapoint ID, value is an array of widgets. this.addColumns = 0 this.addRows = 0 this.editedWidget = null this.widgetsEdited = false this.elEditDashboard.addEventListener('click', () => this.classList.toggle('edit')) this.elAddColumns.addEventListener('click', () => { this.addColumns += 5; this.render() }) this.elAddRows.addEventListener('click', () => { this.addRows += 5; this.render() }) this.elUpdateDashboard.addEventListener('click', () => this.updateDashboard()) this.elEditUpdate.addEventListener('click', () => this.editUpdate()) this.elGridAddLeft.addEventListener('click', () => this.gridAddLeft()) this.elGridAddRight.addEventListener('click', () => this.gridAddRight()) this.elGridAddAbove.addEventListener('click', () => this.gridAddAbove()) this.elGridAddBelow.addEventListener('click', () => this.gridAddBelow()) this.elGridRemove.addEventListener('click', () => this.gridRemove()) this.elGridRemoveMoveLeft.addEventListener('click', () => this.gridRemoveMoveLeft()) this.elGridRemoveMoveUp.addEventListener('click', () => this.gridRemoveMoveUp()) this.elGridAddColumnLeft.addEventListener('click', () => this.gridAddColumnLeft()) this.elGridAddColumnAbove.addEventListener('click', () => this.gridAddColumnAbove()) this.elGridRemoveColumn.addEventListener('click', () => this.gridRemoveColumn()) this.elGridRemoveRow.addEventListener('click', () => this.gridRemoveRow()) this.render() this.fetchData() // Retrieve one as fast as possible since setInterval doesn't run before an interval. setInterval(() => this.fetchData(), 1000) }// }}} render() {// {{{ const widgets = [] this.widgets = new Map() this.occupiedCells = new Map() let maxX = 0 let maxY = 0 for (const wd of this.data.Widgets) { if (!this.widgets.has(wd.DatapointID)) this.widgets.set(wd.DatapointID, []) // widgetRefs modifies the array in the map directly. const widgetRefs = this.widgets.get(wd.DatapointID) const widget = SmonWidget.create(wd.Type, wd) widget.addEventListener('click', () => this.editCell(widget)) widgetRefs.push(widget) widgets.push(widget) maxX = Math.max(maxX, wd.X) maxY = Math.max(maxY, wd.Y) this.occupiedCells.set(`${wd.X}x${wd.Y}`, true) for (let x = 0; x <= wd.SpanX; x++) this.occupiedCells.set(`${wd.X + x}x${wd.Y}`, true) for (let y = 0; y <= wd.SpanY; y++) this.occupiedCells.set(`${wd.X}x${wd.Y + y}`, true) } maxX += this.addColumns maxY += this.addRows // Empty elements are created to fill up the empty cells in the grid. // This is needed as placeholders for grid and dragdrop targets when editing. for (let x = 1; x <= maxX; x++) for (let y = 1; y <= maxY; y++) { if (this.occupiedCells.has(`${x}x${y}`)) continue const empty = new SmonWidgetEmpty({ X: x, Y: y }) empty.addEventListener('click', () => this.editCell(empty)) widgets.push(empty) } this.elUpdateDashboard.disabled = !this.widgetsEdited this.elGrid.replaceChildren(...widgets) }// }}} error(msg) {// {{{ this.elError.innerText = msg if (msg === '') this.elError.classList.remove('show') else this.elError.classList.add('show') }// }}} async editUpdate() {// {{{ const changes = await this.editedWidget.editUpdate() this.widgetsEdited = true let replacedWidget = false if (changes.add) { const coords = `${changes.add.data.X}x${changes.add.data.Y}` for (const w of this.data.Widgets) { if (w.X === changes.add.data.X && w.Y === changes.add.data.Y) { alert(`A widget already exist at ${coords}`) return } } if (!replacedWidget) this.data.Widgets.push(changes.add.data) } this.elEdit.close() this.editedWidget = null this.render() }// }}} editCell(el) {// {{{ if (!this.classList.contains('edit')) return this.editedWidget = el const components = el.edit() this.elEditComponents.replaceChildren(...components) this.elEdit.showModal() }// }}} async fetchData() {// {{{ try { const res = await fetch(`/dashboard/values`) const json = await res.json() if (!json.OK) throw new Error(json.Error) for (const dp of json.Values) { const widgets = this.widgets.get(dp.ID) || [] for (const w of widgets) { w.setValue(dp.Value, dp.Valid) w.render() } } this.error('') } catch (e) { console.error(e) this.error(e.message) // All widgets are set in a NULL state to visualize the error. const allDpIDs = this.widgets.keys() for (const id of allDpIDs) { const widgets = this.widgets.get(id) || [] for (const w of widgets) { w.setValue(null, false) w.render() } } } }// }}} async updateDashboard() {// {{{ try { const res = await fetch('/dashboard/update', { method: 'POST', body: JSON.stringify(this.data), }) const json = await res.json() if (!json.OK) throw new Error(json.Error) this.widgetsEdited = false this.render() } catch (e) { console.error(e) alert(e.message) } }// }}} gridEdit() {// {{{ this.widgetsEdited = true this.elEdit.close() this.editedWidget = null this.render() }// }}} gridAddLeft() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y for (const w of this.data.Widgets) if (w.X >= fromX && w.Y == fromY) w.X++ this.gridEdit() }// }}} gridAddRight() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y for (const w of this.data.Widgets) if (w.X > fromX && w.Y == fromY) w.X++ this.gridEdit() }// }}} gridAddAbove() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y for (const w of this.data.Widgets) if (w.X == fromX && w.Y >= fromY) w.Y++ this.gridEdit() }// }}} gridAddBelow() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y for (const w of this.data.Widgets) if (w.X == fromX && w.Y > fromY) w.Y++ this.gridEdit() }// }}} gridAddColumnLeft() {// {{{ const fromX = this.editedWidget.data.X for (const w of this.data.Widgets) if (w.X >= fromX) w.X++ this.gridEdit() }// }}} gridAddColumnAbove() {// {{{ const fromY = this.editedWidget.data.Y for (const w of this.data.Widgets) if (w.Y >= fromY) w.Y++ this.gridEdit() }// }}} gridRemoveColumn() {// {{{ // Keep all widgets that's not on the edited widget's X coordinate. this.data.Widgets = this.data.Widgets.filter(w => w.X != this.editedWidget.data.X ) for (const w of this.data.Widgets) if (w.X >= this.editedWidget.data.X) w.X-- this.gridEdit() }// }}} gridRemoveRow() {// {{{ // Keep all widgets that's not on the edited widget's X coordinate. this.data.Widgets = this.data.Widgets.filter(w => w.Y != this.editedWidget.data.Y ) for (const w of this.data.Widgets) if (w.Y >= this.editedWidget.data.Y) w.Y-- this.gridEdit() }// }}} gridRemove() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y this.data.Widgets = this.data.Widgets.filter(w => w.X != fromX || w.Y != fromY ) this.gridEdit() }// }}} gridRemoveMoveLeft() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y this.data.Widgets = this.data.Widgets.filter(w => w.X != fromX || w.Y != fromY ) for (const w of this.data.Widgets) if (w.X > fromX && w.Y == fromY) w.X-- this.gridEdit() }// }}} gridRemoveMoveUp() {// {{{ const fromX = this.editedWidget.data.X const fromY = this.editedWidget.data.Y this.data.Widgets = this.data.Widgets.filter(w => w.X != fromX || w.Y != fromY ) for (const w of this.data.Widgets) if (w.X == fromX && w.Y > fromY) w.Y-- this.gridEdit() }// }}} }// }}} class SmonWidget extends CustomHTMLElement {// {{{ static create(t, data) {// {{{ data.Type = t switch (t) { case WIDGET_SPACE: return new SmonWidgetSpace(data) case WIDGET_LABEL: return new SmonWidgetLabel(data) case WIDGET_ONOFF: return new SmonWidgetOnOff(data) default: alert(`Unknown widget type: ${t} (${typeof t})`) } }// }}} constructor(data) {// {{{ super(true) this.data = data this.value = null this.editDialog = null if (!this.data.Attributes) this.data.Attributes = {} }// }}} render() {// {{{ this.style.gridColumn = this.data.X this.style.gridRow = this.data.Y if (this.data.SpanX > 0) this.style.gridColumn = `${this.data.X} / ${this.data.X + this.data.SpanX + 1}` if (this.data.SpanY > 0) this.style.gridRow = `${this.data.Y} / ${this.data.Y + this.data.SpanY + 1}` if (this.data.Attributes.BorderTop) { if (this.data.Attributes.Color) this.style.borderTop = `1px solid ${this.data.Attributes.Color}` else this.style.borderTop = `1px solid currentColor` } if (this.data.Attributes.BorderBottom) { if (this.data.Attributes.Color) this.style.borderBottom = `1px solid ${this.data.Attributes.Color}` else this.style.borderBottom = `1px solid currentColor` } if (this.data.Attributes.BorderLeft) { if (this.data.Attributes.Color) this.style.borderLeft = `1px solid ${this.data.Attributes.Color}` else this.style.borderLeft = `1px solid currentColor` } if (this.data.Attributes.BorderRight) { if (this.data.Attributes.Color) this.style.borderRight = `1px solid ${this.data.Attributes.Color}` else this.style.borderRight = `1px solid currentColor` } }// }}} setValue(v, valid) {// {{{ this.value = valid ? v : null }// }}} edit() {// {{{ if (this.editDialog !== null) return const components = this.commonEditWidgets() components.push(...this.editWidget()) return components }// }}} commonEditWidgets() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = `
Borders
Spanning
X
Y
Font
Size
Color
` this.editDiv.style.marginBottom = '16px' this.elBorderTop = this.editDiv.querySelector('input[name="top"]') this.elBorderBottom = this.editDiv.querySelector('input[name="bottom"]') this.elBorderLeft = this.editDiv.querySelector('input[name="left"]') this.elBorderRight = this.editDiv.querySelector('input[name="right"]') this.elSpanX = this.editDiv.querySelector('input[name="span-x"]') this.elSpanY = this.editDiv.querySelector('input[name="span-y"]') this.elFontSize = this.editDiv.querySelector('input[name="font-size"]') this.elUseColor = this.editDiv.querySelector('input[name="use-color"]') this.elColor = this.editDiv.querySelector('input[name="color"]') this.elColor.addEventListener('input', () => this.elUseColor.checked = true) this.elBorderTop.checked = this.data.Attributes.BorderTop this.elBorderBottom.checked = this.data.Attributes.BorderBottom this.elBorderLeft.checked = this.data.Attributes.BorderLeft this.elBorderRight.checked = this.data.Attributes.BorderRight this.elSpanX.value = this.data.SpanX this.elSpanY.value = this.data.SpanY this.elFontSize.value = this.data.Attributes.Size || '' this.elUseColor.checked = this.data.Attributes.Color ? true : false this.elColor.value = this.data.Attributes.Color || '' return [this.editDiv] }// }}} // edit dialog wants to update the widget. async editUpdate() {// {{{ this.data.Attributes.BorderTop = this.elBorderTop.checked ? 'true' : '' this.data.Attributes.BorderBottom = this.elBorderBottom.checked ? 'true' : '' this.data.Attributes.BorderLeft = this.elBorderLeft.checked ? 'true' : '' this.data.Attributes.BorderRight = this.elBorderRight.checked ? 'true' : '' this.data.SpanX = parseInt(this.elSpanX.value) this.data.SpanY = parseInt(this.elSpanY.value) this.data.Attributes.Size = this.elFontSize.value this.data.Attributes.Color = this.elUseColor.checked ? this.elColor.value : '' return await this.updateWidget() }// }}} // Many widgets can have labels. // This function applies common attributes like size and color. applyLabelAttributes(el) {// {{{ if (this.data.Attributes?.Size) el.style.fontSize = this.data.Attributes.Size if (this.data.Attributes?.Color) el.style.color = this.data.Attributes.Color if (this.data.Attributes?.Underline) if (this.data.Attributes?.Color) el.style.borderBottom = `1px solid ${this.data.Attributes.Color}` else el.style.borderBottom = `1px solid currentColor` }// }}} }// }}} class SmonWidgetEmpty extends SmonWidget {// {{{ static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` ` }// }}} constructor(data) {// {{{ super(data) this.render() }// }}} render() {// {{{ super.render() }// }}} editWidget() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = `
Add widget
` return [this.editDiv] }// }}} async updateWidget() {// {{{ const t = parseInt(this.editDiv.querySelector('select').value) const widget = SmonWidget.create(t, JSON.parse(JSON.stringify(this.data))) return { add: widget } }// }}} }// }}} class SmonWidgetSpace extends SmonWidget {// {{{ static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor(data) {// {{{ super(data) this.render() }// }}} render() {// {{{ super.render() this.elSpacer.style.minWidth = this.data.Attributes.Width || '16px' this.elSpacer.style.minHeight = this.data.Attributes.Height || '16px' }// }}} editWidget() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = `
Spacing
Width
Height
` this.elWidth = this.editDiv.querySelector('[name="width"]') this.elHeight = this.editDiv.querySelector('[name="height"]') this.elWidth.value = this.data.Attributes?.Width || '' this.elHeight.value = this.data.Attributes?.Height || '' return [this.editDiv] }// }}} async updateWidget() {// {{{ this.data.Attributes.Width = this.elWidth.value this.data.Attributes.Height = this.elHeight.value return {} }// }}} }// }}} class SmonWidgetLabel extends SmonWidget {// {{{ static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor(data) {// {{{ super(data) this.render() }// }}} render() {// {{{ super.render() this.elLabel.innerText = this.data.Attributes?.Label || '[set label]' this.applyLabelAttributes(this.elLabel) }// }}} editWidget() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = `
Label
` this.editDiv.querySelector('input').value = this.data.Attributes.Label || '[set label]' return [this.editDiv] }// }}} async updateWidget() {// {{{ this.data.Attributes.Label = this.editDiv.querySelector('input').value return {} }// }}} }// }}} class SmonWidgetOnOff extends SmonWidget {// {{{ static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` }// }}} constructor(data) {// {{{ super(data) this.render() }// }}} render() {// {{{ super.render() let img switch (this.value) { case 0: case 'OFF': img = 'widget_light_red.svg' break case 1: case 'ON': img = 'widget_light_green.svg' break default: img = 'widget_light_off.svg' } this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`) this.elLabel.innerText = this.data.Attributes.Label || '[set label]' this.applyLabelAttributes(this.elLabel) }// }}} editWidget() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = `
Label
` this.editDiv.querySelector('input').value = this.data.Attributes.Label || '[set label]' return [this.editDiv] }// }}} async updateWidget() {// {{{ this.data.Attributes.Label = this.editDiv.querySelector('input').value return {} }// }}} }// }}} customElements.define("smon-widget", SmonWidget) customElements.define("smon-widget-empty", SmonWidgetEmpty) customElements.define("smon-widget-space", SmonWidgetSpace) customElements.define("smon-widget-label", SmonWidgetLabel) customElements.define("smon-widget-onoff", SmonWidgetOnOff) customElements.define("smon-dashboard", SmonDashboard)