Folder reorganize 1
This commit is contained in:
158
assets/js/boiler-panels.js
Normal file
158
assets/js/boiler-panels.js
Normal file
@@ -0,0 +1,158 @@
|
||||
(() => {
|
||||
const PANEL_SELECTOR = '[data-refresh-url="data/boilers.php"]';
|
||||
const TABLE_SELECTOR = '[data-boiler-section]';
|
||||
const STORAGE_KEY = 'lasuca.boilerPanels';
|
||||
|
||||
const loadState = () => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(stored);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (error) {
|
||||
console.warn('Unable to load boiler panel state from storage.', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const saveState = (state) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist boiler panel state.', error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCollapsedState = (table, collapsed) => {
|
||||
const rows = Array.from(table.rows).slice(1);
|
||||
rows.forEach((row) => {
|
||||
row.style.display = collapsed ? 'none' : '';
|
||||
});
|
||||
table.classList.toggle('is-collapsed', collapsed);
|
||||
};
|
||||
|
||||
const formatSummary = (value, units) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return 'Steam Flow: —';
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
const formatted = Number.isFinite(numeric)
|
||||
? numeric.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: value;
|
||||
|
||||
const unitPart = units ? ` ${units}` : '';
|
||||
return `Steam Flow: ${formatted}${unitPart}`;
|
||||
};
|
||||
|
||||
const initialiseTable = (table, state) => {
|
||||
const id = table.dataset.boilerSection;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = table.querySelector('tr');
|
||||
const headerCell = headerRow ? headerRow.querySelector('td, th') : null;
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (table.dataset.boilerTitle || headerCell.textContent || '').trim() || 'Boiler';
|
||||
const steamValue = table.dataset.steamValue || '';
|
||||
const steamUnits = table.dataset.steamUnits || '';
|
||||
|
||||
const targetId = table.id || `${id}-table`;
|
||||
table.id = targetId;
|
||||
|
||||
let headerWrapper = headerCell.querySelector('.boiler-panel__header');
|
||||
let toggleButton;
|
||||
let summary;
|
||||
|
||||
if (!headerWrapper) {
|
||||
headerWrapper = document.createElement('div');
|
||||
headerWrapper.className = 'boiler-panel__header';
|
||||
|
||||
toggleButton = document.createElement('button');
|
||||
toggleButton.type = 'button';
|
||||
toggleButton.className = 'boiler-panel__toggle';
|
||||
toggleButton.setAttribute('data-boiler-toggle', id);
|
||||
toggleButton.setAttribute('aria-controls', targetId);
|
||||
toggleButton.innerHTML = `
|
||||
<span class="boiler-panel__title">${title}</span>
|
||||
<span class="boiler-panel__indicator" aria-hidden="true"></span>
|
||||
`;
|
||||
|
||||
summary = document.createElement('span');
|
||||
summary.className = 'boiler-panel__summary';
|
||||
summary.setAttribute('data-boiler-summary', '');
|
||||
summary.textContent = formatSummary(steamValue, steamUnits);
|
||||
|
||||
headerWrapper.appendChild(toggleButton);
|
||||
headerWrapper.appendChild(summary);
|
||||
|
||||
headerCell.innerHTML = '';
|
||||
headerCell.appendChild(headerWrapper);
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const nextCollapsed = !table.classList.contains('is-collapsed');
|
||||
applyCollapsedState(table, nextCollapsed);
|
||||
toggleButton.setAttribute('aria-expanded', (!nextCollapsed).toString());
|
||||
state[id] = nextCollapsed;
|
||||
saveState(state);
|
||||
});
|
||||
|
||||
table.dataset.boilerInitialised = 'true';
|
||||
} else {
|
||||
toggleButton = headerWrapper.querySelector('.boiler-panel__toggle');
|
||||
summary = headerWrapper.querySelector('[data-boiler-summary]');
|
||||
}
|
||||
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-controls', targetId);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
summary.textContent = formatSummary(steamValue, steamUnits);
|
||||
}
|
||||
|
||||
const collapsed = state[id] === true;
|
||||
applyCollapsedState(table, collapsed);
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', (!collapsed).toString());
|
||||
}
|
||||
};
|
||||
|
||||
const initialisePanel = (panel) => {
|
||||
const state = loadState();
|
||||
const tables = panel.querySelectorAll(TABLE_SELECTOR);
|
||||
tables.forEach((table) => initialiseTable(table, state));
|
||||
};
|
||||
|
||||
const handlePanelUpdate = (event) => {
|
||||
if (!event || !event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
initialisePanel(event.currentTarget);
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const panel = document.querySelector(PANEL_SELECTOR);
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.addEventListener('livepanel:updated', handlePanelUpdate);
|
||||
|
||||
if (panel.innerHTML.trim().length) {
|
||||
initialisePanel(panel);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setup, { once: true });
|
||||
} else {
|
||||
setup();
|
||||
}
|
||||
})();
|
||||
158
assets/js/cvp-panels.js
Normal file
158
assets/js/cvp-panels.js
Normal file
@@ -0,0 +1,158 @@
|
||||
(() => {
|
||||
const PANEL_SELECTOR = '[data-refresh-url="data/cvp.php"]';
|
||||
const TABLE_SELECTOR = '[data-boiler-section]';
|
||||
const STORAGE_KEY = 'lasuca.boilerPanels';
|
||||
|
||||
const loadState = () => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(stored);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (error) {
|
||||
console.warn('Unable to load boiler panel state from storage.', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const saveState = (state) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist boiler panel state.', error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCollapsedState = (table, collapsed) => {
|
||||
const rows = Array.from(table.rows).slice(1);
|
||||
rows.forEach((row) => {
|
||||
row.style.display = collapsed ? 'none' : '';
|
||||
});
|
||||
table.classList.toggle('is-collapsed', collapsed);
|
||||
};
|
||||
|
||||
const formatSummary = (value, units) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
const formatted = Number.isFinite(numeric)
|
||||
? numeric.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: value;
|
||||
|
||||
const unitPart = units ? ` ${units}` : '';
|
||||
return `Steam Flow: ${formatted}${unitPart}`;
|
||||
};
|
||||
|
||||
const initialiseTable = (table, state) => {
|
||||
const id = table.dataset.boilerSection;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = table.querySelector('tr');
|
||||
const headerCell = headerRow ? headerRow.querySelector('td, th') : null;
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (table.dataset.boilerTitle || headerCell.textContent || '').trim() || 'Boiler';
|
||||
const steamValue = table.dataset.steamValue || '';
|
||||
const steamUnits = table.dataset.steamUnits || '';
|
||||
|
||||
const targetId = table.id || `${id}-table`;
|
||||
table.id = targetId;
|
||||
|
||||
let headerWrapper = headerCell.querySelector('.boiler-panel__header');
|
||||
let toggleButton;
|
||||
let summary;
|
||||
|
||||
if (!headerWrapper) {
|
||||
headerWrapper = document.createElement('div');
|
||||
headerWrapper.className = 'boiler-panel__header';
|
||||
|
||||
toggleButton = document.createElement('button');
|
||||
toggleButton.type = 'button';
|
||||
toggleButton.className = 'boiler-panel__toggle';
|
||||
toggleButton.setAttribute('data-boiler-toggle', id);
|
||||
toggleButton.setAttribute('aria-controls', targetId);
|
||||
toggleButton.innerHTML = `
|
||||
<span class="boiler-panel__title">${title}</span>
|
||||
<span class="boiler-panel__indicator" aria-hidden="true"></span>
|
||||
`;
|
||||
|
||||
summary = document.createElement('span');
|
||||
summary.className = 'boiler-panel__summary';
|
||||
summary.setAttribute('data-boiler-summary', '');
|
||||
summary.textContent = formatSummary(steamValue, steamUnits);
|
||||
|
||||
headerWrapper.appendChild(toggleButton);
|
||||
headerWrapper.appendChild(summary);
|
||||
|
||||
headerCell.innerHTML = '';
|
||||
headerCell.appendChild(headerWrapper);
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const nextCollapsed = !table.classList.contains('is-collapsed');
|
||||
applyCollapsedState(table, nextCollapsed);
|
||||
toggleButton.setAttribute('aria-expanded', (!nextCollapsed).toString());
|
||||
state[id] = nextCollapsed;
|
||||
saveState(state);
|
||||
});
|
||||
|
||||
table.dataset.boilerInitialised = 'true';
|
||||
} else {
|
||||
toggleButton = headerWrapper.querySelector('.boiler-panel__toggle');
|
||||
summary = headerWrapper.querySelector('[data-boiler-summary]');
|
||||
}
|
||||
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-controls', targetId);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
summary.textContent = formatSummary(steamValue, steamUnits);
|
||||
}
|
||||
|
||||
const collapsed = state[id] === true;
|
||||
applyCollapsedState(table, collapsed);
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', (!collapsed).toString());
|
||||
}
|
||||
};
|
||||
|
||||
const initialisePanel = (panel) => {
|
||||
const state = loadState();
|
||||
const tables = panel.querySelectorAll(TABLE_SELECTOR);
|
||||
tables.forEach((table) => initialiseTable(table, state));
|
||||
};
|
||||
|
||||
const handlePanelUpdate = (event) => {
|
||||
if (!event || !event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
initialisePanel(event.currentTarget);
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const panel = document.querySelector(PANEL_SELECTOR);
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.addEventListener('livepanel:updated', handlePanelUpdate);
|
||||
|
||||
if (panel.innerHTML.trim().length) {
|
||||
initialisePanel(panel);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setup, { once: true });
|
||||
} else {
|
||||
setup();
|
||||
}
|
||||
})();
|
||||
158
assets/js/fabrication-panels.js
Normal file
158
assets/js/fabrication-panels.js
Normal file
@@ -0,0 +1,158 @@
|
||||
(() => {
|
||||
const PANEL_SELECTOR = '[data-refresh-url="data/fabrication.php"]';
|
||||
const TABLE_SELECTOR = '[data-boiler-section]';
|
||||
const STORAGE_KEY = 'lasuca.boilerPanels';
|
||||
|
||||
const loadState = () => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(stored);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
||||
} catch (error) {
|
||||
console.warn('Unable to load boiler panel state from storage.', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const saveState = (state) => {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist boiler panel state.', error);
|
||||
}
|
||||
};
|
||||
|
||||
const applyCollapsedState = (table, collapsed) => {
|
||||
const rows = Array.from(table.rows).slice(1);
|
||||
rows.forEach((row) => {
|
||||
row.style.display = collapsed ? 'none' : '';
|
||||
});
|
||||
table.classList.toggle('is-collapsed', collapsed);
|
||||
};
|
||||
|
||||
const formatSummary = (value, units) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
const formatted = Number.isFinite(numeric)
|
||||
? numeric.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: value;
|
||||
|
||||
const unitPart = units ? ` ${units}` : '';
|
||||
return `Steam Flow: ${formatted}${unitPart}`;
|
||||
};
|
||||
|
||||
const initialiseTable = (table, state) => {
|
||||
const id = table.dataset.boilerSection;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRow = table.querySelector('tr');
|
||||
const headerCell = headerRow ? headerRow.querySelector('td, th') : null;
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = (table.dataset.boilerTitle || headerCell.textContent || '').trim() || 'Boiler';
|
||||
const steamValue = table.dataset.steamValue || '';
|
||||
const steamUnits = table.dataset.steamUnits || '';
|
||||
|
||||
const targetId = table.id || `${id}-table`;
|
||||
table.id = targetId;
|
||||
|
||||
let headerWrapper = headerCell.querySelector('.boiler-panel__header');
|
||||
let toggleButton;
|
||||
let summary;
|
||||
|
||||
if (!headerWrapper) {
|
||||
headerWrapper = document.createElement('div');
|
||||
headerWrapper.className = 'boiler-panel__header';
|
||||
|
||||
toggleButton = document.createElement('button');
|
||||
toggleButton.type = 'button';
|
||||
toggleButton.className = 'boiler-panel__toggle';
|
||||
toggleButton.setAttribute('data-boiler-toggle', id);
|
||||
toggleButton.setAttribute('aria-controls', targetId);
|
||||
toggleButton.innerHTML = `
|
||||
<span class="boiler-panel__title">${title}</span>
|
||||
<span class="boiler-panel__indicator" aria-hidden="true"></span>
|
||||
`;
|
||||
|
||||
summary = document.createElement('span');
|
||||
summary.className = 'boiler-panel__summary';
|
||||
summary.setAttribute('data-boiler-summary', '');
|
||||
summary.textContent = formatSummary(steamValue, steamUnits);
|
||||
|
||||
headerWrapper.appendChild(toggleButton);
|
||||
headerWrapper.appendChild(summary);
|
||||
|
||||
headerCell.innerHTML = '';
|
||||
headerCell.appendChild(headerWrapper);
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const nextCollapsed = !table.classList.contains('is-collapsed');
|
||||
applyCollapsedState(table, nextCollapsed);
|
||||
toggleButton.setAttribute('aria-expanded', (!nextCollapsed).toString());
|
||||
state[id] = nextCollapsed;
|
||||
saveState(state);
|
||||
});
|
||||
|
||||
table.dataset.boilerInitialised = 'true';
|
||||
} else {
|
||||
toggleButton = headerWrapper.querySelector('.boiler-panel__toggle');
|
||||
summary = headerWrapper.querySelector('[data-boiler-summary]');
|
||||
}
|
||||
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-controls', targetId);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
summary.textContent = formatSummary(steamValue, steamUnits);
|
||||
}
|
||||
|
||||
const collapsed = state[id] === true;
|
||||
applyCollapsedState(table, collapsed);
|
||||
if (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', (!collapsed).toString());
|
||||
}
|
||||
};
|
||||
|
||||
const initialisePanel = (panel) => {
|
||||
const state = loadState();
|
||||
const tables = panel.querySelectorAll(TABLE_SELECTOR);
|
||||
tables.forEach((table) => initialiseTable(table, state));
|
||||
};
|
||||
|
||||
const handlePanelUpdate = (event) => {
|
||||
if (!event || !event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
initialisePanel(event.currentTarget);
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
const panel = document.querySelector(PANEL_SELECTOR);
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.addEventListener('livepanel:updated', handlePanelUpdate);
|
||||
|
||||
if (panel.innerHTML.trim().length) {
|
||||
initialisePanel(panel);
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setup, { once: true });
|
||||
} else {
|
||||
setup();
|
||||
}
|
||||
})();
|
||||
75
assets/js/mobile-header.js
Normal file
75
assets/js/mobile-header.js
Normal file
@@ -0,0 +1,75 @@
|
||||
(() => {
|
||||
const HEADER_SELECTOR = '.app-header';
|
||||
const HIDDEN_CLASS = 'app-header--hidden';
|
||||
const MOBILE_BREAKPOINT = 960;
|
||||
const SCROLL_THRESHOLD = 12;
|
||||
|
||||
let lastScrollY = window.scrollY || window.pageYOffset || 0;
|
||||
let isHidden = false;
|
||||
|
||||
const header = document.querySelector(HEADER_SELECTOR);
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
const shouldEnable = () => window.innerWidth <= MOBILE_BREAKPOINT && !prefersReducedMotion.matches;
|
||||
|
||||
const showHeader = (force = false) => {
|
||||
if (isHidden || force) {
|
||||
header.classList.remove(HIDDEN_CLASS);
|
||||
isHidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
const hideHeader = () => {
|
||||
if (!isHidden) {
|
||||
header.classList.add(HIDDEN_CLASS);
|
||||
isHidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentY = window.scrollY || window.pageYOffset || 0;
|
||||
|
||||
if (!shouldEnable() || currentY <= 0) {
|
||||
showHeader(true);
|
||||
lastScrollY = currentY;
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = currentY - lastScrollY;
|
||||
|
||||
if (Math.abs(delta) <= SCROLL_THRESHOLD) {
|
||||
lastScrollY = currentY;
|
||||
return;
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
hideHeader();
|
||||
} else {
|
||||
showHeader();
|
||||
}
|
||||
|
||||
lastScrollY = currentY;
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
lastScrollY = window.scrollY || window.pageYOffset || 0;
|
||||
if (!shouldEnable()) {
|
||||
showHeader(true);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
if (typeof prefersReducedMotion.addEventListener === 'function') {
|
||||
prefersReducedMotion.addEventListener('change', handleResize);
|
||||
} else if (typeof prefersReducedMotion.addListener === 'function') {
|
||||
prefersReducedMotion.addListener(handleResize);
|
||||
}
|
||||
|
||||
handleResize();
|
||||
})();
|
||||
261
assets/js/refresh.js
Normal file
261
assets/js/refresh.js
Normal file
@@ -0,0 +1,261 @@
|
||||
class LivePanel {
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.url = element.dataset.refreshUrl;
|
||||
|
||||
const requestedInterval = Number(element.dataset.refreshInterval || 2000);
|
||||
this.baseInterval = Number.isFinite(requestedInterval) && requestedInterval > 0 ? requestedInterval : 2000;
|
||||
|
||||
const minIntervalAttr = Number(element.dataset.refreshMinInterval);
|
||||
const maxIntervalAttr = Number(element.dataset.refreshMaxInterval);
|
||||
|
||||
this.minInterval = Number.isFinite(minIntervalAttr) && minIntervalAttr > 0 ? minIntervalAttr : this.baseInterval;
|
||||
this.maxInterval = Number.isFinite(maxIntervalAttr) && maxIntervalAttr > 0 ? Math.max(maxIntervalAttr, this.minInterval) : this.baseInterval * 4;
|
||||
|
||||
this.currentInterval = this.baseInterval;
|
||||
this.lastEtag = null;
|
||||
this.lastModified = null;
|
||||
this.timeoutId = null;
|
||||
this.isFetching = false;
|
||||
|
||||
if (!this.url) {
|
||||
console.warn('LivePanel requires a data-refresh-url attribute.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderLoadingState();
|
||||
this.start();
|
||||
LivePanel.instances.push(this);
|
||||
}
|
||||
|
||||
renderLoadingState() {
|
||||
if (!this.element.querySelector('.panel-loading')) {
|
||||
this.element.innerHTML = `
|
||||
<div class="panel-loading" role="status" aria-live="polite">
|
||||
<span class="spinner" aria-hidden="true"></span>
|
||||
<span>Loading latest data…</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
this.stop();
|
||||
this.currentInterval = this.baseInterval;
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timeoutId) {
|
||||
window.clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextFetch(delay) {
|
||||
if (this.baseInterval <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.timeoutId) {
|
||||
window.clearTimeout(this.timeoutId);
|
||||
}
|
||||
|
||||
let nextDelay = typeof delay === 'number' ? delay : this.currentInterval;
|
||||
|
||||
if (nextDelay !== 0) {
|
||||
nextDelay = Math.min(Math.max(nextDelay, this.minInterval), this.maxInterval);
|
||||
}
|
||||
|
||||
this.timeoutId = window.setTimeout(() => this.fetchData(), nextDelay);
|
||||
}
|
||||
|
||||
async fetchData() {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
};
|
||||
|
||||
if (this.lastEtag) {
|
||||
headers['If-None-Match'] = this.lastEtag;
|
||||
}
|
||||
|
||||
if (this.lastModified) {
|
||||
headers['If-Modified-Since'] = this.lastModified;
|
||||
}
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
headers,
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
if (response.status === 304) {
|
||||
this.currentInterval = Math.min(this.currentInterval * 1.25, this.maxInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const etag = response.headers.get('ETag');
|
||||
if (etag) {
|
||||
this.lastEtag = etag;
|
||||
}
|
||||
|
||||
const lastModified = response.headers.get('Last-Modified');
|
||||
if (lastModified) {
|
||||
this.lastModified = lastModified;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
if (html.trim().length) {
|
||||
this.element.innerHTML = html;
|
||||
}
|
||||
|
||||
const updateEvent = new CustomEvent('livepanel:updated', {
|
||||
detail: {
|
||||
panel: this,
|
||||
html
|
||||
}
|
||||
});
|
||||
this.element.dispatchEvent(updateEvent);
|
||||
|
||||
this.currentInterval = this.baseInterval;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh panel', error);
|
||||
this.element.innerHTML = `
|
||||
<div class="panel-error" role="alert">
|
||||
<strong>Unable to load data.</strong>
|
||||
<span>Please check your connection.</span>
|
||||
</div>
|
||||
`;
|
||||
this.lastEtag = null;
|
||||
this.lastModified = null;
|
||||
this.currentInterval = Math.min(
|
||||
Math.max(this.currentInterval * 1.5, this.minInterval),
|
||||
this.maxInterval
|
||||
);
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
if (this.baseInterval > 0) {
|
||||
this.scheduleNextFetch(this.currentInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static pauseAll() {
|
||||
LivePanel.instances.forEach((panel) => panel.stop());
|
||||
}
|
||||
|
||||
static resumeAll() {
|
||||
LivePanel.instances.forEach((panel) => panel.start());
|
||||
}
|
||||
}
|
||||
|
||||
LivePanel.instances = [];
|
||||
|
||||
function initialiseLivePanels() {
|
||||
const panels = document.querySelectorAll('[data-refresh-url]');
|
||||
panels.forEach((panel) => {
|
||||
if (!panel.dataset.livePanelInitialised) {
|
||||
panel.dataset.livePanelInitialised = 'true';
|
||||
new LivePanel(panel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupBackToTop() {
|
||||
const backToTop = document.querySelector('[data-back-to-top]');
|
||||
if (!backToTop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleVisibility = () => {
|
||||
if (window.scrollY > 320) {
|
||||
backToTop.classList.add('is-visible');
|
||||
} else {
|
||||
backToTop.classList.remove('is-visible');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', toggleVisibility, { passive: true });
|
||||
backToTop.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
function setupMobileNav() {
|
||||
const toggle = document.querySelector('[data-menu-toggle]');
|
||||
const sidebar = document.querySelector('.app-sidebar');
|
||||
|
||||
if (!toggle || !sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const expanded = toggle.getAttribute('aria-expanded') === 'true';
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
|
||||
sidebar.classList.toggle('is-open');
|
||||
});
|
||||
}
|
||||
|
||||
window.popupCenter = function popupCenter(pageURL, title = 'Details', width = 900, height = 640) {
|
||||
const screenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX;
|
||||
const screenTop = window.screenTop !== undefined ? window.screenTop : window.screenY;
|
||||
const dualScreenWidth = window.innerWidth || document.documentElement.clientWidth || screen.width;
|
||||
const dualScreenHeight = window.innerHeight || document.documentElement.clientHeight || screen.height;
|
||||
|
||||
const left = screenLeft + Math.max(0, (dualScreenWidth - width) / 2);
|
||||
const top = screenTop + Math.max(0, (dualScreenHeight - height) / 2);
|
||||
|
||||
const features = [
|
||||
'toolbar=no',
|
||||
'location=no',
|
||||
'directories=no',
|
||||
'status=no',
|
||||
'menubar=no',
|
||||
'scrollbars=yes',
|
||||
'resizable=yes',
|
||||
`width=${width}`,
|
||||
`height=${height}`,
|
||||
`top=${top}`,
|
||||
`left=${left}`
|
||||
].join(',');
|
||||
|
||||
const newWindow = window.open(pageURL, title, features);
|
||||
if (newWindow && newWindow.focus) {
|
||||
newWindow.focus();
|
||||
}
|
||||
return newWindow;
|
||||
};
|
||||
|
||||
window.popitup = function popitup(url, title = 'Details') {
|
||||
const popup = window.open(url, title, 'width=420,height=520,scrollbars=yes');
|
||||
if (popup && popup.focus) {
|
||||
popup.focus();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
initialiseLivePanels();
|
||||
setupBackToTop();
|
||||
setupMobileNav();
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
LivePanel.pauseAll();
|
||||
} else {
|
||||
LivePanel.resumeAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
920
assets/js/tag-controls.js
Normal file
920
assets/js/tag-controls.js
Normal file
@@ -0,0 +1,920 @@
|
||||
(() => {
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined || Number.isNaN(Number(value))) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
const absValue = Math.abs(numeric);
|
||||
let maximumFractionDigits = 0;
|
||||
|
||||
if (absValue !== Math.trunc(absValue)) {
|
||||
maximumFractionDigits = absValue < 1 ? 3 : absValue < 10 ? 2 : 1;
|
||||
}
|
||||
|
||||
return numeric.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const app = document.querySelector('[data-control-app]');
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
|
||||
const select = app.querySelector('[data-control-select]');
|
||||
const typeFilter = app.querySelector('[data-control-type-filter]');
|
||||
const valueDisplay = app.querySelector('[data-current-value]');
|
||||
const timestampDisplay = app.querySelector('[data-current-timestamp]');
|
||||
const valueLabel = app.querySelector('[data-value-label]');
|
||||
const pendingIndicator = app.querySelector('[data-pending-indicator]');
|
||||
const numericContainer = app.querySelector('[data-numeric-controls]');
|
||||
const booleanContainer = app.querySelector('[data-boolean-controls]');
|
||||
const stepInput = app.querySelector('[data-step-input]');
|
||||
const setContainer = app.querySelector('[data-set-form]');
|
||||
const setInput = app.querySelector('[data-set-input]');
|
||||
const applyButton = app.querySelector('[data-action="apply"]');
|
||||
const decreaseBtn = app.querySelector('[data-action="decrease"]');
|
||||
const increaseBtn = app.querySelector('[data-action="increase"]');
|
||||
const booleanOffBtn = app.querySelector('[data-boolean="off"]');
|
||||
const booleanOnBtn = app.querySelector('[data-boolean="on"]');
|
||||
const refreshBtn = app.querySelector('[data-action="refresh"]');
|
||||
const status = app.querySelector('[data-status]');
|
||||
|
||||
const defaultNumericSettings = {
|
||||
stepValue: stepInput ? Number(stepInput.value) || 1 : 1,
|
||||
stepAttr: stepInput ? (stepInput.getAttribute('step') || '0.1') : '0.1',
|
||||
stepMin: stepInput ? (stepInput.getAttribute('min') || '') : '',
|
||||
setStepAttr: setInput ? (setInput.getAttribute('step') || '0.1') : '0.1',
|
||||
placeholder: setInput ? (setInput.getAttribute('placeholder') || '') : ''
|
||||
};
|
||||
|
||||
const getUrl = app.dataset.getUrl;
|
||||
const updateUrl = app.dataset.updateUrl;
|
||||
const csrfToken = app.dataset.csrf;
|
||||
|
||||
if (!select || !getUrl || !updateUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholderOption = select.options.length > 0 ? select.options[0] : null;
|
||||
const placeholderDefaultText = placeholderOption ? placeholderOption.textContent : '';
|
||||
|
||||
const state = {
|
||||
busy: false,
|
||||
selectedId: select ? (select.value || null) : null,
|
||||
statusTimeout: null,
|
||||
selectedMode: 'numeric',
|
||||
currentValue: null,
|
||||
stagedValue: null,
|
||||
numericConfig: null,
|
||||
datasetUnits: null,
|
||||
units: null,
|
||||
description: null
|
||||
};
|
||||
|
||||
const parseDatasetNumber = (value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
};
|
||||
|
||||
const extractNumericConfig = (option) => {
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const step = parseDatasetNumber(option.dataset.stepSize);
|
||||
const min = parseDatasetNumber(option.dataset.minValue);
|
||||
const max = parseDatasetNumber(option.dataset.maxValue);
|
||||
const rawPrecision = option.dataset.precision;
|
||||
const precisionNumber =
|
||||
rawPrecision === undefined || rawPrecision === null || rawPrecision === ''
|
||||
? null
|
||||
: Number(rawPrecision);
|
||||
const precision =
|
||||
Number.isFinite(precisionNumber) && precisionNumber >= 0
|
||||
? Math.round(precisionNumber)
|
||||
: null;
|
||||
const units = option.dataset.units && option.dataset.units !== '' ? option.dataset.units : null;
|
||||
const description =
|
||||
option.dataset.description && option.dataset.description !== ''
|
||||
? option.dataset.description
|
||||
: null;
|
||||
|
||||
return {
|
||||
step: typeof step === 'number' && step > 0 ? step : null,
|
||||
min: typeof min === 'number' ? min : null,
|
||||
max: typeof max === 'number' ? max : null,
|
||||
precision,
|
||||
units,
|
||||
description
|
||||
};
|
||||
};
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const applyNumericConstraints = (value) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const config = state.numericConfig;
|
||||
|
||||
if (!config) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let result = value;
|
||||
|
||||
if (typeof config.min === 'number' && result < config.min) {
|
||||
result = config.min;
|
||||
}
|
||||
|
||||
if (typeof config.max === 'number' && result > config.max) {
|
||||
result = config.max;
|
||||
}
|
||||
|
||||
if (typeof config.precision === 'number' && config.precision >= 0) {
|
||||
result = Number(result.toFixed(config.precision));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getUnitsLabel = () => state.units || state.datasetUnits || null;
|
||||
|
||||
const applyNumericConfig = (option) => {
|
||||
const config = extractNumericConfig(option);
|
||||
state.numericConfig = config;
|
||||
state.datasetUnits = config ? config.units : null;
|
||||
state.description = config ? config.description : null;
|
||||
state.units = null;
|
||||
|
||||
if (stepInput) {
|
||||
const stepValue =
|
||||
config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepValue;
|
||||
stepInput.value = stepValue;
|
||||
stepInput.step =
|
||||
config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepAttr;
|
||||
|
||||
if (config && typeof config.min === 'number') {
|
||||
stepInput.min = config.min;
|
||||
} else if (defaultNumericSettings.stepMin !== '') {
|
||||
stepInput.min = defaultNumericSettings.stepMin;
|
||||
} else {
|
||||
stepInput.removeAttribute('min');
|
||||
}
|
||||
}
|
||||
|
||||
if (setInput) {
|
||||
if (config && typeof config.min === 'number') {
|
||||
setInput.min = config.min;
|
||||
} else {
|
||||
setInput.removeAttribute('min');
|
||||
}
|
||||
|
||||
if (config && typeof config.max === 'number') {
|
||||
setInput.max = config.max;
|
||||
} else {
|
||||
setInput.removeAttribute('max');
|
||||
}
|
||||
|
||||
if (config && typeof config.step === 'number') {
|
||||
setInput.step = config.step;
|
||||
} else {
|
||||
setInput.step = defaultNumericSettings.setStepAttr;
|
||||
}
|
||||
|
||||
if (config && config.units) {
|
||||
setInput.placeholder = config.units;
|
||||
} else {
|
||||
setInput.placeholder = defaultNumericSettings.placeholder;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getActionableElements = () => [
|
||||
stepInput,
|
||||
setInput,
|
||||
applyButton,
|
||||
decreaseBtn,
|
||||
increaseBtn,
|
||||
refreshBtn,
|
||||
booleanOffBtn,
|
||||
booleanOnBtn
|
||||
].filter(Boolean);
|
||||
|
||||
const resolveMode = (hint) => (hint === 'boolean' ? 'boolean' : 'numeric');
|
||||
|
||||
const isBooleanMode = () => state.selectedMode === 'boolean';
|
||||
|
||||
const valuesEqual = (left, right) => {
|
||||
if (left === null || left === undefined) {
|
||||
return right === null || right === undefined;
|
||||
}
|
||||
|
||||
if (right === null || right === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const leftNumber = Number(left);
|
||||
const rightNumber = Number(right);
|
||||
|
||||
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
|
||||
return left === right;
|
||||
}
|
||||
|
||||
return Math.abs(leftNumber - rightNumber) < 1e-9;
|
||||
};
|
||||
|
||||
const parseNumericValue = (value) => {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
};
|
||||
|
||||
const formatBooleanDisplay = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return '—';
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return String(value);
|
||||
}
|
||||
return numeric >= 0.5 ? 'ON' : 'OFF';
|
||||
};
|
||||
|
||||
const updateBooleanButtons = (value) => {
|
||||
if (!booleanOnBtn || !booleanOffBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numeric = Number(value);
|
||||
|
||||
if (!Number.isFinite(numeric)) {
|
||||
booleanOnBtn.classList.remove('button--toggle-active');
|
||||
booleanOffBtn.classList.remove('button--toggle-active');
|
||||
booleanOnBtn.setAttribute('aria-pressed', 'false');
|
||||
booleanOffBtn.setAttribute('aria-pressed', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const isOn = numeric >= 0.5;
|
||||
|
||||
booleanOnBtn.classList.toggle('button--toggle-active', isOn);
|
||||
booleanOffBtn.classList.toggle('button--toggle-active', !isOn);
|
||||
booleanOnBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
|
||||
booleanOffBtn.setAttribute('aria-pressed', !isOn ? 'true' : 'false');
|
||||
};
|
||||
|
||||
const getVisualValue = () => {
|
||||
if (isBooleanMode()) {
|
||||
return state.currentValue;
|
||||
}
|
||||
|
||||
if (Number.isFinite(state.stagedValue)) {
|
||||
return state.stagedValue;
|
||||
}
|
||||
|
||||
return state.currentValue;
|
||||
};
|
||||
|
||||
const updatePendingIndicator = () => {
|
||||
if (!pendingIndicator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = Boolean(
|
||||
state.selectedId &&
|
||||
!isBooleanMode() &&
|
||||
Number.isFinite(state.stagedValue) &&
|
||||
!valuesEqual(state.stagedValue, state.currentValue)
|
||||
);
|
||||
|
||||
pendingIndicator.hidden = !pending;
|
||||
};
|
||||
|
||||
const refreshValueDisplay = () => {
|
||||
if (valueDisplay) {
|
||||
const visualValue = getVisualValue();
|
||||
valueDisplay.textContent = isBooleanMode()
|
||||
? formatBooleanDisplay(visualValue)
|
||||
: formatValue(visualValue);
|
||||
}
|
||||
|
||||
updatePendingIndicator();
|
||||
};
|
||||
|
||||
const isApplyReady = () => Boolean(
|
||||
state.selectedId &&
|
||||
!isBooleanMode() &&
|
||||
Number.isFinite(state.stagedValue) &&
|
||||
!valuesEqual(state.stagedValue, state.currentValue)
|
||||
);
|
||||
|
||||
const updateApplyAvailability = () => {
|
||||
if (!applyButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyButton.disabled =
|
||||
state.busy ||
|
||||
!isApplyReady();
|
||||
};
|
||||
|
||||
const setMode = (modeHint) => {
|
||||
const mode = resolveMode(modeHint);
|
||||
state.selectedMode = mode;
|
||||
|
||||
if (numericContainer) {
|
||||
numericContainer.hidden = mode !== 'numeric';
|
||||
}
|
||||
|
||||
if (booleanContainer) {
|
||||
booleanContainer.hidden = mode !== 'boolean';
|
||||
}
|
||||
|
||||
if (valueLabel) {
|
||||
const unitsLabel = getUnitsLabel();
|
||||
valueLabel.textContent = mode === 'boolean'
|
||||
? 'Current state'
|
||||
: `Current value${unitsLabel ? ` (${unitsLabel})` : ''}`;
|
||||
}
|
||||
|
||||
if (setInput) {
|
||||
if (mode === 'numeric') {
|
||||
const unitsLabel = getUnitsLabel();
|
||||
setInput.placeholder = unitsLabel || defaultNumericSettings.placeholder;
|
||||
} else {
|
||||
setInput.placeholder = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBooleanMode() && setInput && !Number.isFinite(state.stagedValue) && Number.isFinite(state.currentValue)) {
|
||||
const constrainedValue = applyNumericConstraints(state.currentValue);
|
||||
setInput.value = Number.isFinite(constrainedValue) ? constrainedValue : state.currentValue;
|
||||
}
|
||||
|
||||
if (isBooleanMode()) {
|
||||
updateBooleanButtons(state.currentValue);
|
||||
}
|
||||
|
||||
refreshValueDisplay();
|
||||
updateApplyAvailability();
|
||||
};
|
||||
|
||||
const getSelectedOption = () => {
|
||||
if (!state.selectedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from(select.options).find((option) => option.value === state.selectedId) || null;
|
||||
};
|
||||
|
||||
const updateModeFromSelection = () => {
|
||||
const option = getSelectedOption();
|
||||
applyNumericConfig(option);
|
||||
const modeHint = option ? option.dataset.controlMode : 'numeric';
|
||||
setMode(modeHint);
|
||||
};
|
||||
|
||||
const applyTypeFilter = () => {
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterValue = typeFilter ? typeFilter.value : '';
|
||||
let visibleOptionExists = false;
|
||||
|
||||
Array.from(select.options).forEach((option, index) => {
|
||||
if (index === 0) {
|
||||
option.hidden = false;
|
||||
option.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const optionType = option.dataset.typeKey || '';
|
||||
const isVisible = !filterValue || optionType === filterValue;
|
||||
option.hidden = !isVisible;
|
||||
option.disabled = !isVisible;
|
||||
|
||||
if (isVisible) {
|
||||
visibleOptionExists = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (select.value) {
|
||||
const selected = select.options[select.selectedIndex];
|
||||
if (!selected || selected.hidden || selected.disabled) {
|
||||
select.value = '';
|
||||
state.selectedId = null;
|
||||
clearDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeFilter) {
|
||||
if (visibleOptionExists) {
|
||||
typeFilter.removeAttribute('data-filter-empty');
|
||||
} else {
|
||||
typeFilter.setAttribute('data-filter-empty', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholderOption) {
|
||||
placeholderOption.textContent = visibleOptionExists ? placeholderDefaultText : 'No tags for this type';
|
||||
}
|
||||
|
||||
updateModeFromSelection();
|
||||
updateControlAvailability();
|
||||
};
|
||||
|
||||
const setStatus = (message, type = 'info') => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.statusTimeout) {
|
||||
window.clearTimeout(state.statusTimeout);
|
||||
state.statusTimeout = null;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
status.hidden = true;
|
||||
status.textContent = '';
|
||||
status.removeAttribute('data-status-type');
|
||||
return;
|
||||
}
|
||||
|
||||
status.hidden = false;
|
||||
status.textContent = message;
|
||||
status.dataset.statusType = type;
|
||||
|
||||
const timeout = type === 'error' ? 6000 : 2400;
|
||||
state.statusTimeout = window.setTimeout(() => {
|
||||
status.hidden = true;
|
||||
status.textContent = '';
|
||||
status.removeAttribute('data-status-type');
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
const setBusy = (busy) => {
|
||||
state.busy = busy;
|
||||
select.disabled = busy;
|
||||
updateControlAvailability();
|
||||
};
|
||||
|
||||
const updateControlAvailability = () => {
|
||||
const hasSelection = Boolean(state.selectedId);
|
||||
const numericDisabled = state.busy || !hasSelection || isBooleanMode();
|
||||
const booleanDisabled = state.busy || !hasSelection || !isBooleanMode();
|
||||
|
||||
if (stepInput) {
|
||||
stepInput.disabled = numericDisabled;
|
||||
}
|
||||
if (setInput) {
|
||||
setInput.disabled = numericDisabled;
|
||||
}
|
||||
if (decreaseBtn) {
|
||||
decreaseBtn.disabled = numericDisabled;
|
||||
}
|
||||
if (increaseBtn) {
|
||||
increaseBtn.disabled = numericDisabled;
|
||||
}
|
||||
if (booleanOffBtn) {
|
||||
booleanOffBtn.disabled = booleanDisabled;
|
||||
}
|
||||
if (booleanOnBtn) {
|
||||
booleanOnBtn.disabled = booleanDisabled;
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = state.busy || !hasSelection;
|
||||
}
|
||||
|
||||
if (typeFilter) {
|
||||
typeFilter.disabled = state.busy;
|
||||
}
|
||||
|
||||
updateApplyAvailability();
|
||||
};
|
||||
|
||||
const clearDisplay = () => {
|
||||
state.currentValue = null;
|
||||
state.stagedValue = null;
|
||||
|
||||
applyNumericConfig(null);
|
||||
|
||||
if (timestampDisplay) {
|
||||
timestampDisplay.textContent = '';
|
||||
}
|
||||
if (setInput) {
|
||||
setInput.value = '';
|
||||
}
|
||||
|
||||
updateBooleanButtons(null);
|
||||
setMode('numeric');
|
||||
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = '—';
|
||||
}
|
||||
if (pendingIndicator) {
|
||||
pendingIndicator.hidden = true;
|
||||
}
|
||||
};
|
||||
|
||||
const applyData = (data) => {
|
||||
if (!data) {
|
||||
clearDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
state.units = data.units || null;
|
||||
if (!state.units && state.datasetUnits) {
|
||||
state.units = state.datasetUnits;
|
||||
}
|
||||
if (data.description) {
|
||||
state.description = data.description;
|
||||
}
|
||||
|
||||
const resolvedMode = resolveMode(data.control_mode);
|
||||
setMode(resolvedMode);
|
||||
|
||||
if (timestampDisplay) {
|
||||
timestampDisplay.textContent = data.displayTimestamp || data.timestamp || '';
|
||||
}
|
||||
|
||||
if (resolvedMode === 'boolean') {
|
||||
const booleanValue = Number(data.value);
|
||||
state.currentValue = Number.isFinite(booleanValue) ? booleanValue : null;
|
||||
state.stagedValue = null;
|
||||
updateBooleanButtons(state.currentValue);
|
||||
refreshValueDisplay();
|
||||
updateApplyAvailability();
|
||||
} else {
|
||||
const numericValue = parseNumericValue(data.value);
|
||||
state.currentValue = numericValue;
|
||||
stageNumericValue(numericValue);
|
||||
}
|
||||
|
||||
updateControlAvailability();
|
||||
};
|
||||
|
||||
const stageNumericValue = (value, { updateInput = true } = {}) => {
|
||||
if (isBooleanMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
state.stagedValue = null;
|
||||
|
||||
if (setInput && updateInput) {
|
||||
setInput.value = '';
|
||||
}
|
||||
|
||||
refreshValueDisplay();
|
||||
updateApplyAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
let numericValue = parseNumericValue(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
state.stagedValue = null;
|
||||
|
||||
if (setInput && updateInput) {
|
||||
setInput.value = '';
|
||||
}
|
||||
|
||||
refreshValueDisplay();
|
||||
updateApplyAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
numericValue = applyNumericConstraints(numericValue);
|
||||
state.stagedValue = numericValue;
|
||||
|
||||
if (setInput && updateInput) {
|
||||
setInput.value = Number.isFinite(numericValue) ? numericValue : '';
|
||||
}
|
||||
|
||||
refreshValueDisplay();
|
||||
updateApplyAvailability();
|
||||
};
|
||||
|
||||
const adjustStagedValue = (direction) => {
|
||||
if (!state.selectedId || isBooleanMode() || !stepInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = parseNumericValue(stepInput.value);
|
||||
|
||||
if (step === null || step <= 0) {
|
||||
setStatus('Step size must be greater than zero.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const base = Number.isFinite(state.stagedValue)
|
||||
? state.stagedValue
|
||||
: (Number.isFinite(state.currentValue) ? state.currentValue : 0);
|
||||
|
||||
const newValue = base + direction * Math.abs(step);
|
||||
const constrainedValue = applyNumericConstraints(newValue);
|
||||
stageNumericValue(constrainedValue);
|
||||
|
||||
if (!state.busy) {
|
||||
setStatus('Adjustments pending. Press Apply to commit.', 'info');
|
||||
}
|
||||
|
||||
updateControlAvailability();
|
||||
};
|
||||
|
||||
const buildUrlWithId = (baseUrl, id) => {
|
||||
const url = new URL(baseUrl, window.location.href);
|
||||
url.searchParams.set('id', id);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const fetchJson = async (input, init = {}) => {
|
||||
const response = await fetch(input, init);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok || !payload.success) {
|
||||
const message = payload.message || `Request failed (HTTP ${response.status})`;
|
||||
if (payload && payload.details) {
|
||||
console.error('Request failed with details:', payload.details);
|
||||
}
|
||||
throw new Error(payload && payload.details ? `${message} — ${payload.details}` : message);
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
const loadTagValue = async (silent = false) => {
|
||||
if (!state.selectedId) {
|
||||
clearDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 2;
|
||||
const retryDelayMs = 600;
|
||||
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
if (!silent) {
|
||||
const message = attempt === 1
|
||||
? 'Loading current value…'
|
||||
: 'Retrying load after a transient error…';
|
||||
const tone = attempt === 1 ? 'info' : 'warning';
|
||||
setStatus(message, tone);
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
buildUrlWithId(getUrl, state.selectedId),
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
cache: 'no-cache'
|
||||
}
|
||||
);
|
||||
|
||||
applyData(payload.data);
|
||||
if (!silent) {
|
||||
setStatus('Latest value loaded.', 'success');
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`Attempt ${attempt} to load tag value failed.`, error);
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await delay(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error && error.message ? error.message : 'Failed to read tag value.';
|
||||
setStatus(message, 'error');
|
||||
clearDisplay();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendUpdate = async (body, successMessage = 'Value updated successfully.') => {
|
||||
if (!state.selectedId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy(true);
|
||||
setStatus('Applying update…', 'info');
|
||||
|
||||
const payload = await fetchJson(updateUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-Token': csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: state.selectedId,
|
||||
...body
|
||||
})
|
||||
});
|
||||
|
||||
applyData(payload.data);
|
||||
|
||||
let statusTone = 'success';
|
||||
let statusMessage = successMessage;
|
||||
|
||||
if (payload.log) {
|
||||
const { success, driver, error, path } = payload.log;
|
||||
|
||||
if (!success) {
|
||||
statusTone = 'warning';
|
||||
statusMessage = 'Value updated, but audit log failed. Check with an administrator.';
|
||||
console.error('Control write audit log failed to persist.', payload.log);
|
||||
} else if (driver && driver !== 'database') {
|
||||
statusTone = 'warning';
|
||||
statusMessage = 'Value updated. Audit log stored in fallback location.';
|
||||
console.warn('Control write audit log fallback engaged.', payload.log);
|
||||
if (path) {
|
||||
console.info('Fallback log path:', path);
|
||||
}
|
||||
} else {
|
||||
console.debug('Control write audit logged successfully.');
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.debug('Audit log diagnostics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(statusMessage, statusTone);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus(error.message, 'error');
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (select) {
|
||||
select.addEventListener('change', () => {
|
||||
state.selectedId = select.value || null;
|
||||
|
||||
if (!state.selectedId) {
|
||||
clearDisplay();
|
||||
updateControlAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
updateModeFromSelection();
|
||||
state.currentValue = null;
|
||||
state.stagedValue = null;
|
||||
if (setInput) {
|
||||
setInput.value = '';
|
||||
}
|
||||
refreshValueDisplay();
|
||||
updateControlAvailability();
|
||||
loadTagValue(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeFilter) {
|
||||
typeFilter.addEventListener('change', () => {
|
||||
applyTypeFilter();
|
||||
const filterEmpty = typeFilter.getAttribute('data-filter-empty') === 'true';
|
||||
|
||||
if (!select.value) {
|
||||
state.selectedId = null;
|
||||
clearDisplay();
|
||||
if (filterEmpty) {
|
||||
setStatus('No tags available for this type.', 'warning');
|
||||
} else {
|
||||
setStatus('');
|
||||
}
|
||||
} else {
|
||||
state.selectedId = select.value;
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
updateControlAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
if (decreaseBtn) {
|
||||
decreaseBtn.addEventListener('click', () => {
|
||||
adjustStagedValue(-1);
|
||||
});
|
||||
}
|
||||
|
||||
if (increaseBtn) {
|
||||
increaseBtn.addEventListener('click', () => {
|
||||
adjustStagedValue(1);
|
||||
});
|
||||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
loadTagValue();
|
||||
});
|
||||
}
|
||||
|
||||
if (setInput) {
|
||||
setInput.addEventListener('input', () => {
|
||||
if (!state.selectedId || isBooleanMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = setInput.value;
|
||||
|
||||
if (raw === '') {
|
||||
stageNumericValue(null, { updateInput: false });
|
||||
updateControlAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
const numeric = parseNumericValue(raw);
|
||||
|
||||
if (numeric === null) {
|
||||
state.stagedValue = null;
|
||||
refreshValueDisplay();
|
||||
updateApplyAvailability();
|
||||
updateControlAvailability();
|
||||
return;
|
||||
}
|
||||
|
||||
stageNumericValue(numeric, { updateInput: false });
|
||||
updateControlAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
if (applyButton) {
|
||||
applyButton.addEventListener('click', () => {
|
||||
if (!isApplyReady()) {
|
||||
setStatus('Provide a numeric value to apply.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = state.stagedValue;
|
||||
sendUpdate({ value: target }, 'Value applied successfully.');
|
||||
});
|
||||
}
|
||||
|
||||
if (booleanOffBtn) {
|
||||
booleanOffBtn.addEventListener('click', () => {
|
||||
if (!state.selectedId || !isBooleanMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (valuesEqual(state.currentValue, 0)) {
|
||||
setStatus('Switch is already Off.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
sendUpdate({ value: 0 }, 'Switch turned off.');
|
||||
});
|
||||
}
|
||||
|
||||
if (booleanOnBtn) {
|
||||
booleanOnBtn.addEventListener('click', () => {
|
||||
if (!state.selectedId || !isBooleanMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (valuesEqual(state.currentValue, 1)) {
|
||||
setStatus('Switch is already On.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
sendUpdate({ value: 1 }, 'Switch turned on.');
|
||||
});
|
||||
}
|
||||
|
||||
applyTypeFilter();
|
||||
updateControlAvailability();
|
||||
|
||||
if (state.selectedId) {
|
||||
updateModeFromSelection();
|
||||
loadTagValue(true);
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user