Added start of editable dashboard

This commit is contained in:
Magnus Åhall 2026-08-20 11:13:03 +02:00
parent 1af3f59b1a
commit c3fe5951d4
8 changed files with 502 additions and 92 deletions

View file

@ -7,8 +7,8 @@
viewBox="0 0 8.466666 8.4666661"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
sodipodi:docname="widget_light_off.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="widget_light_red.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
@ -25,11 +25,11 @@
inkscape:document-units="px"
showborder="false"
inkscape:zoom="32"
inkscape:cx="18.171875"
inkscape:cy="16.25"
inkscape:window-width="1916"
inkscape:window-height="1161"
inkscape:window-x="0"
inkscape:cx="18.1875"
inkscape:cy="16.265625"
inkscape:window-width="1093"
inkscape:window-height="1401"
inkscape:window-x="2560"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
@ -40,7 +40,7 @@
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:#d41400;stroke:none;stroke-width:1.00012;fill-opacity:1"
style="fill:#ff2a2a;stroke:none;stroke-width:0.79399899;fill-opacity:1"
id="circle1"
cx="4.2333331"
cy="4.2333331"

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -1,122 +1,408 @@
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
export class SmonDashboard extends CustomHTMLElement {
static {
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() {
}// }}}
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() {
render() {// {{{
const widgets = []
for (const wd of this.data.Widgets) {
const widget = SmonWidget.create(wd.Type, wd)
widgets.push(widget)
}
this.elGrid.replaceChildren(...widgets)
}
}
this.widgets = new Map()
class SmonWidget extends CustomHTMLElement {
static create(t, data) {
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) {
}// }}}
constructor(data) {// {{{
super(true)
this.data = data
}
this.value = null
this.editDialog = null
}// }}}
render() {
render() {// {{{
this.style.gridColumn = this.data.X
this.style.gridRow = this.data.Y
}
}
class SmonWidgetLabel extends SmonWidget {
static {
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;
font-size: 1.5em;
margin-bottom: 8px;
}
</style>
<div data-el="label"></div>
`
}
constructor(data) {
}// }}}
constructor(data) {// {{{
super(data)
this.render()
}
render() {
}// }}}
render() {// {{{
super.render()
this.elLabel.innerText = this.data.Attributes?.Label || '[set Label]'
}
}
class SmonWidgetOnOff extends SmonWidget {
static {
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;
:host {
display: grid;
grid-template-columns: min-content 1fr;
align-items: center;
grid-gap: 8px;
}
img {
height: 24px;
width: 24px;
}
font-size: 1.25em;
}
</style>
<img data-el="status">
<div data-el="label"></div>
`
}
constructor(data) {
}// }}}
constructor(data) {// {{{
super(data)
this.render()
}
render() {
}// }}}
render() {// {{{
super.render()
let img
switch (this.data.Attributes.Status) {
switch (this.value) {
case 1:
case 'ON':
img = 'widget_light_green.svg'
break
case 0:
case 'OFF':
img = 'widget_light_red.svg'
break
@ -127,11 +413,25 @@ class SmonWidgetOnOff extends SmonWidget {
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)