Files
interval_viz/html/app.js
T
traberph 8f8c662e5e
Build image / build (push) Successful in 34s
update branding and ci
2026-09-26 12:02:12 +02:00

359 lines
13 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const LANG_KEY = 'intervalviz.lang';
const DATASET_KEY = 'intervalviz.dataset';
const DATA_DIR = 'data/';
const HOUR = 3600e3;
const state = {
lang: 'en',
events: [], // [{ state, t }] sorted by time (ms)
phases: [], // [{ start, end, dur }] completed on-phases (ms)
dataMin: 0,
dataMax: 0,
range: null, // [from, to] in ms, or null = everything
datasets: [], // CSV file names in data/
source: '', // name of the loaded dataset or local file
localFile: false, // source was opened via "Load CSV…"
error: null, // { key, params } so it can be re-rendered on language change
};
const $ = id => document.getElementById(id);
const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
// ---------- i18n ----------
function t(key, params = {}) {
const s = I18N[state.lang][key] ?? I18N.en[key] ?? key;
return typeof s === 'string' ? s.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? '') : s;
}
function detectLang() {
try {
const saved = localStorage.getItem(LANG_KEY);
if (saved in I18N) return saved;
} catch { /* storage unavailable */ }
return (navigator.language || '').toLowerCase().startsWith('de') ? 'de' : 'en';
}
function setLang(lang) {
state.lang = lang;
try { localStorage.setItem(LANG_KEY, lang); } catch { /* storage unavailable */ }
document.documentElement.lang = lang;
document.title = t('title');
document.querySelectorAll('[data-i18n]').forEach(el => {
// only the note contains markup, and it comes from our own dictionary
if (el.dataset.i18n === 'note') el.innerHTML = t('note');
else el.textContent = t(el.dataset.i18n);
});
$('dataset').setAttribute('aria-label', t('dataset'));
document.querySelectorAll('[data-lang]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.lang === lang)));
render();
}
// ---------- formatting ----------
const pad = n => String(n).padStart(2, '0');
// Local-time string for Plotly: it treats date strings without zone as-is,
// so the axis shows the viewer's local time instead of UTC.
function plotlyDate(ms) {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const fromPlotlyDate = v => typeof v === 'number' ? v : new Date(String(v).replace(' ', 'T')).getTime();
const fmtDateTime = ms => new Date(ms).toLocaleString(t('locale'),
{ weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const fmtTime = ms => new Date(ms).toLocaleTimeString(t('locale'), { hour: '2-digit', minute: '2-digit' });
const fmtTableDate = ms => new Date(ms).toLocaleString(t('locale'),
{ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
function fmtDur(ms) {
if (ms == null || !Number.isFinite(ms)) return '–';
const u = t('units');
const s = Math.round(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60);
return h ? `${h}${u.h} ${pad(m)}${u.m}` : `${m}${u.m} ${pad(s % 60)}${u.s}`;
}
function fmtSpan(ms) {
const u = t('units');
const m = Math.round(ms / 60000), d = Math.floor(m / 1440), h = Math.floor(m % 1440 / 60);
if (d) return h ? `${d} ${u.d} ${h} ${u.h}` : `${d} ${u.d}`;
return h ? `${h} ${u.h} ${pad(m % 60)} ${u.m}` : `${m} ${u.m}`;
}
const fmtPercent = x => new Intl.NumberFormat(t('locale'),
{ style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(x);
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
// ---------- data ----------
class CsvError extends Error {
constructor(key) { super(key); this.key = key; }
}
// Parses a Home Assistant history export (entity_id,state,last_changed).
function parseCsv(text) {
const lines = text.replace(/^/, '').trim().split(/\r?\n/);
const cells = line => line.split(',').map(c => c.trim().replace(/^"(.*)"$/, '$1'));
const head = cells(lines.shift() || '');
const iState = head.indexOf('state'), iTime = head.indexOf('last_changed');
if (iState < 0 || iTime < 0) throw new CsvError('errColumns');
const events = lines
.map(cells)
.map(c => ({ state: c[iState], t: Date.parse(c[iTime]) }))
.filter(e => e.state && Number.isFinite(e.t))
.sort((a, b) => a.t - b.t);
if (!events.length) throw new CsvError('errEmpty');
// An on-phase starts at the first "on" and ends at the next non-"on" state.
const phases = [];
let start = null;
for (const e of events) {
if (e.state === 'on') {
if (start === null) start = e.t;
} else if (start !== null) {
phases.push({ start, end: e.t, dur: e.t - start });
start = null;
}
}
return { events, phases };
}
function load(text, source, localFile = false) {
try {
const { events, phases } = parseCsv(text);
Object.assign(state, {
events, phases, source, localFile, error: null, range: null,
dataMin: events[0].t, dataMax: events[events.length - 1].t,
});
} catch (err) {
if (!(err instanceof CsvError)) throw err;
state.error = { key: err.key };
}
render();
}
function stats(from, to) {
const inRange = state.phases.filter(p => p.start >= from && p.start <= to);
const d = inRange.map(p => p.dur).sort((a, b) => a - b);
const n = d.length;
const mean = arr => arr.length ? arr.reduce((s, v) => s + v, 0) / arr.length : null;
const onTime = state.phases.reduce((s, p) => s + Math.max(0, Math.min(p.end, to) - Math.max(p.start, from)), 0);
return {
inRange, n,
min: n ? d[0] : null,
max: n ? d[n - 1] : null,
mean: mean(d),
median: n ? (n % 2 ? d[(n - 1) / 2] : (d[n / 2 - 1] + d[n / 2]) / 2) : null,
onTime,
duty: to > from ? onTime / (to - from) : null,
meanGap: mean(inRange.slice(1).map((p, i) => p.start - inRange[i].end)),
};
}
// ---------- rendering ----------
const tilesHtml = tiles => tiles.map(([key, value, cls = '']) =>
`<div class="tile"><div class="k">${escapeHtml(t(key))}</div><div class="v ${cls}">${escapeHtml(value)}</div></div>`).join('');
function render() {
renderDatasetSelect();
const src = $('source');
if (state.error) {
src.textContent = t(state.error.key, state.error.params);
src.className = 'sub error';
} else {
// with a dropdown the selected entry already names the source
src.textContent = $('dataset').hidden ? state.source || t('loading') : '';
src.className = 'sub';
}
if (!state.events.length) return;
$('dataTiles').innerHTML = tilesHtml([
['from', fmtDateTime(state.dataMin), 'sm'],
['to', fmtDateTime(state.dataMax), 'sm'],
['span', fmtSpan(state.dataMax - state.dataMin)],
['events', state.events.length],
['onPhases', state.phases.length],
]);
drawChart();
renderStats();
}
function renderDatasetSelect() {
const select = $('dataset');
select.hidden = state.datasets.length < 2;
if (select.hidden) return;
const option = (value, label, extra = '') =>
`<option value="${escapeHtml(value)}"${extra}>${escapeHtml(label)}</option>`;
select.innerHTML = state.datasets.map(name => option(name, name)).join('') +
(state.localFile ? option('', `${t('localFile')}: ${state.source}`, ' disabled') : '');
select.value = state.localFile ? '' : state.source;
}
function drawChart() {
const accent = css('--accent'), ink = css('--ink'), ink2 = css('--ink-2'), line = css('--line');
// Step line for context; unknown/unavailable states become gaps.
const stepTrace = {
type: 'scatter', mode: 'lines', hoverinfo: 'skip', connectgaps: false,
x: state.events.map(e => plotlyDate(e.t)),
y: state.events.map(e => e.state === 'on' ? 1 : e.state === 'off' ? 0 : null),
line: { shape: 'hv', width: 1.5, color: accent },
};
// One bar per on-phase spanning its real duration; it carries the hover tooltip.
const phaseTrace = {
type: 'bar',
x: state.phases.map(p => plotlyDate(p.start + p.dur / 2)),
y: state.phases.map(() => 1),
width: state.phases.map(p => p.dur),
customdata: state.phases.map(p => [fmtDateTime(p.start), fmtTime(p.end), fmtDur(p.dur)]),
hovertemplate: '<b>%{customdata[2]}</b><br>%{customdata[0]} → %{customdata[1]}<extra></extra>',
marker: { color: css('--accent-fill'), line: { width: 0 } },
};
const axis = { gridcolor: line, zerolinecolor: line, linecolor: line, tickfont: { color: ink2 } };
const layout = {
margin: { l: 40, r: 12, t: 8, b: 36 },
paper_bgcolor: 'rgba(0,0,0,0)', plot_bgcolor: 'rgba(0,0,0,0)',
font: { family: 'system-ui, sans-serif', color: ink2, size: 12 },
showlegend: false, dragmode: 'zoom', hovermode: 'x', hoverdistance: 30, bargap: 0,
hoverlabel: { bgcolor: css('--surface'), bordercolor: line, font: { color: ink } },
xaxis: {
...axis, type: 'date',
...(state.range ? { range: state.range.map(plotlyDate) } : { autorange: true }),
},
yaxis: {
...axis, fixedrange: true, range: [-0.05, 1.15],
tickvals: [0, 1], ticktext: [t('off'), t('on')],
},
};
const config = {
responsive: true, displaylogo: false, scrollZoom: true, locale: state.lang,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d'],
};
const chart = $('chart');
Plotly.react(chart, [stepTrace, phaseTrace], layout, config);
if (!chart.dataset.bound) {
chart.dataset.bound = '1';
chart.on('plotly_relayout', onRelayout);
}
}
// Keeps the statistics in sync with zoom / pan / reset in the chart.
function onRelayout(ev) {
if (ev['xaxis.autorange']) state.range = null;
else if (ev['xaxis.range[0]'] !== undefined) state.range = [ev['xaxis.range[0]'], ev['xaxis.range[1]']].map(fromPlotlyDate);
else if (ev['xaxis.range']) state.range = ev['xaxis.range'].map(fromPlotlyDate);
else return;
renderStats();
}
function setRange(range) {
state.range = range;
Plotly.relayout($('chart'), range ? { 'xaxis.range': range.map(plotlyDate) } : { 'xaxis.autorange': true });
renderStats();
}
function renderStats() {
const [from, to] = state.range || [state.dataMin, state.dataMax];
const s = stats(from, to);
$('range').textContent = `${fmtDateTime(from)} → ${fmtDateTime(to)} (${fmtSpan(to - from)})`;
$('tiles').innerHTML = tilesHtml([
['phases', s.n],
['min', fmtDur(s.min)],
['max', fmtDur(s.max)],
['mean', fmtDur(s.mean)],
['median', fmtDur(s.median)],
['totalOn', fmtDur(s.onTime)],
['dutyCycle', s.duty == null ? '–' : fmtPercent(s.duty)],
['meanGap', fmtDur(s.meanGap)],
]);
const first = state.phases.indexOf(s.inRange[0]);
$('rows').innerHTML = s.inRange.map((p, i) => {
const prev = state.phases[first + i - 1];
return `<tr><td>${i + 1}</td><td>${fmtTableDate(p.start)}</td><td>${fmtTableDate(p.end)}</td>` +
`<td class="num">${fmtDur(p.dur)}</td><td class="num">${prev ? fmtDur(p.start - prev.end) : '–'}</td></tr>`;
}).join('');
}
// ---------- wiring ----------
document.querySelectorAll('[data-lang]').forEach(btn =>
btn.addEventListener('click', () => setLang(btn.dataset.lang)));
document.querySelectorAll('[data-span]').forEach(btn => btn.addEventListener('click', () => {
if (!state.events.length) return;
const span = btn.dataset.span;
if (span === 'all') return setRange(null);
const to = state.range ? state.range[1] : state.dataMax;
setRange([to - Number(span) * HOUR, to]);
}));
$('file').addEventListener('change', async e => {
const file = e.target.files[0];
e.target.value = ''; // allow re-selecting the same file
if (file) load(await file.text(), file.name, true);
});
// Escape resets the zoom, like a double-click in the chart
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented || !state.range) return;
if (e.target.closest('select, input')) return; // let open controls handle their own Escape
setRange(null);
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
$('dataset').addEventListener('change', e => loadDataset(e.target.value));
async function loadDataset(name) {
try {
const r = await fetch(DATA_DIR + encodeURIComponent(name), { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
load(await r.text(), name);
// remember only selections that actually loaded
if (!state.error) try { localStorage.setItem(DATASET_KEY, name); } catch { /* storage unavailable */ }
} catch (err) {
state.error = { key: 'errFetch', params: { file: name, status: err.message } };
render();
}
}
// data/ is served as an nginx JSON directory listing: [{ name, type, mtime, size }, …]
async function init() {
setLang(detectLang());
let files = [];
try {
const r = await fetch(DATA_DIR, { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
files = (await r.json())
.filter(f => f.type === 'file' && /\.csv$/i.test(f.name))
.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
state.error = { key: 'errFetch', params: { file: DATA_DIR, status: err.message } };
return render();
}
if (!files.length) {
state.error = { key: 'errNoData' };
return render();
}
state.datasets = files.map(f => f.name);
// last selection if still present, otherwise the last file by name
// (mtimes are useless here: git checkouts in CI give all files the same time)
let saved = null;
try { saved = localStorage.getItem(DATASET_KEY); } catch { /* storage unavailable */ }
loadDataset(state.datasets.includes(saved) ? saved : state.datasets[state.datasets.length - 1]);
}
init();