Files
interval_viz/html/app.js
T
traberphandClaude Opus 5.5 77a594c3af Add heatpump compressor cycle analysis web app
Static page that plots Home Assistant on/off history of the heatpump
compressor with pan/zoom, per-phase hover durations and statistics
(min/max/mean/median, total on-time, duty cycle) for the visible range.
English/German UI. Served by nginx in Docker with basic auth, CSP and
a healthcheck; Plotly is vendored locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:21:07 +02:00

303 lines
11 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 = 'heatpump.lang';
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
source: '',
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);
});
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) {
try {
const { events, phases } = parseCsv(text);
Object.assign(state, {
events, phases, source, 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() {
const src = $('source');
if (state.error) {
src.textContent = t(state.error.key, state.error.params);
src.className = 'sub error';
} else {
src.textContent = 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 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);
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
setLang(detectLang());
fetch('history.csv', { cache: 'no-cache' })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(text => load(text, 'history.csv'))
.catch(err => {
state.error = { key: 'errFetch', params: { status: err.message } };
render();
});