'use strict';
const LANG_KEY = 'heatpump.lang';
const DATASET_KEY = 'heatpump.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 = '']) =>
`
${escapeHtml(t(key))}
${escapeHtml(value)}
`).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 = '') =>
``;
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: '%{customdata[2]}
%{customdata[0]} → %{customdata[1]}',
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 `| ${i + 1} | ${fmtTableDate(p.start)} | ${fmtTableDate(p.end)} | ` +
`${fmtDur(p.dur)} | ${prev ? fmtDur(p.start - prev.end) : '–'} |
`;
}).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();