359 lines
13 KiB
JavaScript
359 lines
13 KiB
JavaScript
'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();
|