437 lines
10 KiB
JavaScript
437 lines
10 KiB
JavaScript
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
|
|
|
export class SmonDashboard extends CustomHTMLElement {// {{{
|
|
static {// {{{
|
|
this.tmpl = document.createElement('template')
|
|
this.tmpl.innerHTML = `
|
|
<style>
|
|
:host {
|
|
--edit-border: 1px solid #888;
|
|
|
|
display: grid;
|
|
}
|
|
|
|
:host(.edit) {
|
|
.el-grid {
|
|
grid-gap: unset;
|
|
border-top: var(--edit-border);
|
|
border-left: var(--edit-border);
|
|
|
|
& > * {
|
|
border-right: var(--edit-border);
|
|
border-bottom: var(--edit-border);
|
|
padding: 8px 8px;
|
|
}
|
|
|
|
& > *:hover {
|
|
background-color: rgba(255, 255, 255, 0.1);
|
|
cursor: pointer;
|
|
}
|
|
|
|
& > .empty {
|
|
display: block;
|
|
}
|
|
}
|
|
|
|
.only-on-edit {
|
|
display: unset;
|
|
}
|
|
}
|
|
|
|
.el-error {
|
|
display: none;
|
|
margin-bottom: 16px;
|
|
width: min-content;
|
|
white-space: nowrap;
|
|
background-color: #ff2a2a;
|
|
color: #fff;
|
|
font-weight: bold;
|
|
font-size: 1.25em;
|
|
padding: 16px 32px;
|
|
border-radius: 8px;
|
|
|
|
&.show {
|
|
display: block;
|
|
}
|
|
}
|
|
|
|
.el-grid {
|
|
display: grid;
|
|
grid-auto-flow: column;
|
|
grid-auto-columns: min-content;
|
|
grid-gap: 8px 16px;
|
|
width: min-content;
|
|
|
|
.empty {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.only-on-edit {
|
|
display: none;
|
|
}
|
|
|
|
button {
|
|
width: min-content;
|
|
white-space: nowrap;
|
|
padding: 4px 8px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
dialog {
|
|
input[type="text"] {
|
|
padding: 4px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
select {
|
|
margin-top: 4px;
|
|
}
|
|
|
|
button {
|
|
margin-bottom: unset;
|
|
}
|
|
}
|
|
|
|
</style>
|
|
|
|
<div style="display: flex; gap: 8px;">
|
|
<button data-el="edit-dashboard">Edit dashboard</button>
|
|
<button class="only-on-edit" data-el="add-columns">Add columns</button>
|
|
<button class="only-on-edit" data-el="add-rows">Add rows</button>
|
|
<button class="only-on-edit" data-el="update-dashboard">Update dashboard</button>
|
|
</div>
|
|
|
|
<div data-el="error"></div>
|
|
<div data-el="grid">
|
|
</div>
|
|
|
|
<dialog data-el="edit">
|
|
<div data-el="edit-components"></div>
|
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
|
<button data-el="edit-update">Update</button>
|
|
<button data-el="edit-remove">Remove</button>
|
|
</div>
|
|
</dialog>
|
|
`
|
|
}// }}}
|
|
constructor() {// {{{
|
|
super(true)
|
|
const script = this.querySelector('script')
|
|
this.data = JSON.parse(script.textContent)
|
|
globalThis.foo = this.data
|
|
this.widgets = new Map() // keyed by datapoint ID, value is an array of widgets.
|
|
this.addColumns = 0
|
|
this.addRows = 0
|
|
this.editedWidget = null
|
|
|
|
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.saveDashboard())
|
|
this.elEditUpdate.addEventListener('click', () => this.editUpdate())
|
|
|
|
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()
|
|
|
|
let maxX = 0
|
|
let maxY = 0
|
|
let occupiedCells = new Map()
|
|
|
|
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.selectCell(widget))
|
|
widgetRefs.push(widget)
|
|
widgets.push(widget)
|
|
|
|
maxX = Math.max(maxX, wd.X)
|
|
maxY = Math.max(maxY, wd.Y)
|
|
|
|
occupiedCells.set(`${wd.X}x${wd.Y}`, true)
|
|
for (let x = 0; x <= wd.SpanX; x++) {
|
|
occupiedCells.set(`${wd.X + x}x${wd.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 (occupiedCells.has(`${x}x${y}`))
|
|
continue
|
|
|
|
const empty = new SmonWidgetEmpty(x, y)
|
|
empty.addEventListener('click', () => this.selectCell(empty))
|
|
widgets.push(empty)
|
|
}
|
|
|
|
this.elGrid.replaceChildren(...widgets)
|
|
}// }}}
|
|
error(msg) {// {{{
|
|
this.elError.innerText = msg
|
|
if (msg === '')
|
|
this.elError.classList.remove('show')
|
|
else
|
|
this.elError.classList.add('show')
|
|
}// }}}
|
|
saveDashboard() {// {{{
|
|
}// }}}
|
|
async editUpdate() {// {{{
|
|
await this.editedWidget.editUpdate()
|
|
this.elEdit.close()
|
|
this.editedWidget = null
|
|
this.render()
|
|
}// }}}
|
|
selectCell(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)
|
|
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)
|
|
w.render()
|
|
}
|
|
}
|
|
}
|
|
}// }}}
|
|
}// }}}
|
|
|
|
class SmonWidget extends CustomHTMLElement {// {{{
|
|
static create(t, data) {// {{{
|
|
switch (t) {
|
|
case 0: return new SmonWidgetLabel(data)
|
|
case 1: return new SmonWidgetOnOff(data)
|
|
}
|
|
}// }}}
|
|
constructor(data) {// {{{
|
|
super(true)
|
|
this.data = data
|
|
this.value = null
|
|
this.editDialog = null
|
|
}// }}}
|
|
|
|
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.Attributes.BorderBottom) {
|
|
if (this.data.Attributes.Color)
|
|
this.style.borderBottom = `1px solid ${this.data.Attributes.Color}`
|
|
else
|
|
this.style.borderBottom = `1px solid currentColor`
|
|
}
|
|
}// }}}
|
|
setValue(v) {// {{{
|
|
this.value = v
|
|
}// }}}
|
|
edit() {// {{{
|
|
if (this.editDialog !== null)
|
|
return
|
|
|
|
const components = this.editWidget()
|
|
return components
|
|
}// }}}
|
|
|
|
// 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 = `
|
|
<style>
|
|
:host {
|
|
min-width: 16px;
|
|
min-height: 16px;
|
|
}
|
|
</style>
|
|
`
|
|
}// }}}
|
|
constructor(x, y) {// {{{
|
|
super({
|
|
Attributes: {},
|
|
X: x,
|
|
Y: y,
|
|
})
|
|
|
|
this.classList.add('empty')
|
|
this.render()
|
|
}// }}}
|
|
render() {// {{{
|
|
super.render()
|
|
}// }}}
|
|
editWidget() {// {{{
|
|
const div = document.createElement('div')
|
|
div.innerHTML = `
|
|
<div>Add widget:</div>
|
|
<select style="width: 100%; padding: 4px 4px">
|
|
<option value="0">Label</option>
|
|
<option value="1">On / off</option>
|
|
</select>
|
|
`
|
|
return [div]
|
|
}// }}}
|
|
}// }}}
|
|
class SmonWidgetLabel extends SmonWidget {// {{{
|
|
static {// {{{
|
|
this.tmpl = document.createElement('template')
|
|
this.tmpl.innerHTML = `
|
|
<style>
|
|
.el-label {
|
|
font-weight: bold;
|
|
margin-bottom: 8px;
|
|
}
|
|
</style>
|
|
<div data-el="label"></div>
|
|
`
|
|
}// }}}
|
|
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 = `
|
|
<div>Label:</div>
|
|
<input type="text">
|
|
`
|
|
this.editDiv.querySelector('input').value = this.data.Attributes.Label
|
|
return [this.editDiv]
|
|
}// }}}
|
|
async editUpdate() {// {{{
|
|
console.log('hum')
|
|
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
|
}// }}}
|
|
}// }}}
|
|
class SmonWidgetOnOff extends SmonWidget {// {{{
|
|
static {// {{{
|
|
this.tmpl = document.createElement('template')
|
|
this.tmpl.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: grid;
|
|
grid-template-columns: min-content 1fr;
|
|
align-items: center;
|
|
grid-gap: 8px;
|
|
}
|
|
|
|
img {
|
|
height: 24px;
|
|
width: 24px;
|
|
}
|
|
|
|
</style>
|
|
<img data-el="status">
|
|
<div data-el="label"></div>
|
|
`
|
|
}// }}}
|
|
constructor(data) {// {{{
|
|
super(data)
|
|
this.render()
|
|
}// }}}
|
|
render() {// {{{
|
|
super.render()
|
|
|
|
let img
|
|
switch (this.value) {
|
|
case 1:
|
|
case 'ON':
|
|
img = 'widget_light_green.svg'
|
|
break
|
|
|
|
case 0:
|
|
case 'OFF':
|
|
img = 'widget_light_red.svg'
|
|
break
|
|
|
|
default:
|
|
img = 'widget_light_off.svg'
|
|
}
|
|
|
|
this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`)
|
|
this.elLabel.innerText = this.data.Attributes.Label
|
|
this.applyLabelAttributes(this.elLabel)
|
|
}// }}}
|
|
editWidget() {// {{{
|
|
this.editDiv = document.createElement('div')
|
|
this.editDiv.innerHTML = `
|
|
<div>Label:</div>
|
|
<input type="text">
|
|
`
|
|
this.editDiv.querySelector('input').value = this.data.Attributes.Label
|
|
return [this.editDiv]
|
|
}// }}}
|
|
async editUpdate() {// {{{
|
|
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
|
}// }}}
|
|
}// }}}
|
|
|
|
|
|
customElements.define("smon-widget", SmonWidget)
|
|
customElements.define("smon-widget-empty", SmonWidgetEmpty)
|
|
customElements.define("smon-widget-label", SmonWidgetLabel)
|
|
customElements.define("smon-widget-onoff", SmonWidgetOnOff)
|
|
customElements.define("smon-dashboard", SmonDashboard)
|