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 = `
`
}// }}}
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 = `
`
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
`
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)