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>
This commit is contained in:
+302
@@ -0,0 +1,302 @@
|
||||
'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();
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// UI strings. Keys used in index.html via data-i18n and in app.js via t().
|
||||
const I18N = {
|
||||
en: {
|
||||
locale: 'en-GB',
|
||||
title: 'Heatpump compressor cycles',
|
||||
loading: 'loading…',
|
||||
dataHeading: 'Data',
|
||||
rangeHeading: 'Selected range',
|
||||
all: 'All',
|
||||
loadCsv: 'Load CSV…',
|
||||
phaseList: 'On-phases in selected range',
|
||||
start: 'Start',
|
||||
end: 'End',
|
||||
duration: 'Duration',
|
||||
gapBefore: 'Gap before',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
span: 'Span',
|
||||
events: 'Events',
|
||||
onPhases: 'On-phases',
|
||||
phases: 'Phases',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mean',
|
||||
median: 'Median',
|
||||
totalOn: 'Total on',
|
||||
dutyCycle: 'Duty cycle',
|
||||
meanGap: 'Mean off-gap',
|
||||
on: 'on',
|
||||
off: 'off',
|
||||
units: { d: 'd', h: 'h', m: 'm', s: 's' },
|
||||
errColumns: 'CSV needs the columns "state" and "last_changed".',
|
||||
errEmpty: 'CSV contains no valid rows.',
|
||||
errFetch: 'Could not load history.csv ({status}) – use “Load CSV…”.',
|
||||
note: 'Hover a phase to see its duration. Drag to zoom, shift+drag or the pan tool to pan, ' +
|
||||
'mouse wheel to zoom, double-click to reset. Statistics use the on-phases that <em>start</em> ' +
|
||||
'within the visible range; total on-time and duty cycle are clipped to the range. ' +
|
||||
'A phase ends at the first state that is not "on" (off, unknown, unavailable); ' +
|
||||
'a phase still running at the end of the data is excluded. Times are shown in your local time zone.',
|
||||
},
|
||||
de: {
|
||||
locale: 'de-DE',
|
||||
title: 'Wärmepumpe – Verdichterzyklen',
|
||||
loading: 'lädt…',
|
||||
dataHeading: 'Daten',
|
||||
rangeHeading: 'Ausgewählter Zeitraum',
|
||||
all: 'Alles',
|
||||
loadCsv: 'CSV laden…',
|
||||
phaseList: 'Laufphasen im ausgewählten Zeitraum',
|
||||
start: 'Start',
|
||||
end: 'Ende',
|
||||
duration: 'Dauer',
|
||||
gapBefore: 'Pause davor',
|
||||
from: 'Von',
|
||||
to: 'Bis',
|
||||
span: 'Zeitraum',
|
||||
events: 'Ereignisse',
|
||||
onPhases: 'Laufphasen',
|
||||
phases: 'Phasen',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mittelwert',
|
||||
median: 'Median',
|
||||
totalOn: 'Laufzeit gesamt',
|
||||
dutyCycle: 'Einschaltanteil',
|
||||
meanGap: 'Ø Pause',
|
||||
on: 'an',
|
||||
off: 'aus',
|
||||
units: { d: 'T', h: 'h', m: 'min', s: 's' },
|
||||
errColumns: 'Die CSV benötigt die Spalten "state" und "last_changed".',
|
||||
errEmpty: 'Die CSV enthält keine gültigen Zeilen.',
|
||||
errFetch: 'history.csv konnte nicht geladen werden ({status}) – bitte „CSV laden…“ verwenden.',
|
||||
note: 'Für die Dauer über eine Phase fahren. Ziehen zum Zoomen, Umschalt+Ziehen oder das Verschieben-Werkzeug ' +
|
||||
'zum Verschieben, Mausrad zum Zoomen, Doppelklick zum Zurücksetzen. Die Statistik berücksichtigt Laufphasen, ' +
|
||||
'die im sichtbaren Zeitraum <em>beginnen</em>; Laufzeit gesamt und Einschaltanteil werden auf den Zeitraum begrenzt. ' +
|
||||
'Eine Phase endet beim ersten Zustand ungleich „on“ (off, unknown, unavailable); ' +
|
||||
'eine am Datenende noch laufende Phase wird nicht gezählt. Zeiten in lokaler Zeitzone.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Heatpump Cycles</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="vendor/plotly.min.js" defer></script>
|
||||
<script src="vendor/plotly-locale-de.js" defer></script>
|
||||
<script src="i18n.js" defer></script>
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1 data-i18n="title">Heatpump compressor cycles</h1>
|
||||
<div class="header-right">
|
||||
<span class="sub" id="source" data-i18n="loading">loading…</span>
|
||||
<div class="lang" role="group" aria-label="Language">
|
||||
<button type="button" data-lang="en">EN</button>
|
||||
<button type="button" data-lang="de">DE</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h2 data-i18n="dataHeading">Data</h2>
|
||||
<div class="tiles" id="dataTiles"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button type="button" data-span="6">6 h</button>
|
||||
<button type="button" data-span="24">24 h</button>
|
||||
<button type="button" data-span="72">3 d</button>
|
||||
<button type="button" data-span="168">7 d</button>
|
||||
<button type="button" data-span="all" data-i18n="all">All</button>
|
||||
<label class="file"><span data-i18n="loadCsv">Load CSV…</span> <input type="file" id="file" accept=".csv,text/csv"></label>
|
||||
<span class="range" id="range"></span>
|
||||
</div>
|
||||
|
||||
<div class="card"><div id="chart"></div></div>
|
||||
|
||||
<h2 data-i18n="rangeHeading">Selected range</h2>
|
||||
<div class="tiles" id="tiles"></div>
|
||||
|
||||
<details>
|
||||
<summary data-i18n="phaseList">On-phases in selected range</summary>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>#</th><th data-i18n="start">Start</th><th data-i18n="end">End</th>
|
||||
<th class="num" data-i18n="duration">Duration</th><th class="num" data-i18n="gapBefore">Gap before</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</details>
|
||||
<p class="note" data-i18n="note"></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,64 @@
|
||||
:root {
|
||||
--bg: #f7f7f5; --surface: #ffffff; --ink: #1b1b1f; --ink-2: #55565e; --muted: #8a8b93;
|
||||
--line: #e4e4e0; --accent: #2a6fdb; --accent-fill: rgba(42, 111, 219, .18); --error: #c0392b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #141417; --surface: #1d1e22; --ink: #ececf0; --ink-2: #b1b2ba; --muted: #7d7e87;
|
||||
--line: #2e2f35; --accent: #6b9ff0; --accent-fill: rgba(107, 159, 240, .22); --error: #ff7b6b;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--ink);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 40px; }
|
||||
|
||||
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; }
|
||||
h1 { font-size: 20px; margin: 0; font-weight: 600; }
|
||||
h2 {
|
||||
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
color: var(--muted); margin: 18px 0 -6px;
|
||||
}
|
||||
.header-right { display: flex; gap: 14px; align-items: center; }
|
||||
.sub { color: var(--ink-2); font-size: 13px; }
|
||||
.error { color: var(--error); }
|
||||
|
||||
button, label.file {
|
||||
font: inherit; font-size: 13px; padding: 5px 11px; border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
||||
}
|
||||
button:hover, label.file:hover { border-color: var(--accent); }
|
||||
label.file:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
label.file input { position: absolute; opacity: 0; width: 1px; height: 1px; }
|
||||
|
||||
.lang { display: flex; }
|
||||
.lang button { border-radius: 0; padding: 4px 9px; }
|
||||
.lang button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.lang button:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; }
|
||||
.lang button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
|
||||
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 14px 0; }
|
||||
.range { color: var(--ink-2); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
||||
#chart { width: 100%; height: 340px; }
|
||||
/* the tooltip already contains the time; hide Plotly's extra x-axis hover label */
|
||||
#chart .hoverlayer .axistext { display: none; }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 14px 0; }
|
||||
.tile { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
|
||||
.tile .k { color: var(--ink-2); font-size: 12px; }
|
||||
.tile .v { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 2px; }
|
||||
.tile .v.sm { font-size: 17px; }
|
||||
|
||||
details { margin-top: 14px; }
|
||||
summary { cursor: pointer; color: var(--ink-2); }
|
||||
table { border-collapse: collapse; width: 100%; margin-top: 8px; font-variant-numeric: tabular-nums; font-size: 13px; }
|
||||
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--ink-2); font-weight: 500; }
|
||||
th.num, td.num { text-align: right; }
|
||||
|
||||
.note { color: var(--muted); font-size: 12px; margin-top: 8px; }
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
var locale={moduleType:"locale",name:"de",dictionary:{Autoscale:"Automatische Skalierung","Box Select":"Rechteckauswahl","Click to enter Colorscale title":"Klicken, um den Farbskalatitel einzugeben","Click to enter Component A title":"Klicken, um den Titel der Komponente A einzugeben","Click to enter Component B title":"Klicken, um den Titel der Komponente B einzugeben","Click to enter Component C title":"Klicken, um den Titel der Komponente C einzugeben","Click to enter Plot title":"Klicken, um den Titel des Graphen einzugeben","Click to enter X axis title":"Klicken, um den Titel der X-Achse einzugeben","Click to enter Y axis title":"Klicken, um den Titel der Y-Achse einzugeben","Compare data on hover":"\xdcber die Daten fahren, um sie zu vergleichen","Double-click on legend to isolate one trace":"Daten isolieren durch Doppelklick in der Legende","Double-click to zoom back out":"Herauszoomen durch Doppelklick","Download plot as a png":"Graphen als PNG herunterladen","Download plot":"Graphen herunterladen","Edit in Chart Studio":"Im Chart Studio bearbeiten","IE only supports svg. Changing format to svg.":"IE unterst\xfctzt nur SVG-Dateien. Format wird zu SVG gewechselt.","Lasso Select":"Lassoauswahl","Orbital rotation":"Orbitalrotation",Pan:"Verschieben","Produced with Plotly.js":"Erstellt mit Plotly.js",Reset:"Zur\xfccksetzen","Reset axes":"Achsen zur\xfccksetzen","Reset camera to default":"Kamera auf Standard zur\xfccksetzen","Reset camera to last save":"Kamera auf letzte Speicherung zur\xfccksetzen","Reset view":"Ansicht zur\xfccksetzen","Reset views":"Ansichten zur\xfccksetzen","Show closest data on hover":"Zeige n\xe4heste Daten beim \xdcberfahren","Snapshot succeeded":"Snapshot erfolgreich","Sorry, there was a problem downloading your snapshot!":"Es gab ein Problem beim Herunterladen des Snapshots","Taking snapshot - this may take a few seconds":"Erstelle einen Snapshot - dies kann einige Sekunden dauern",Zoom:"Zoom","Zoom in":"Hineinzoomen","Zoom out":"Herauszoomen","close:":"Schluss:",trace:"Datenspur","lat:":"Lat.:","lon:":"Lon.:","q1:":"q1:","q3:":"q3:","source:":"Quelle:","target:":"Ziel:","lower fence:":"Untere Schranke:","upper fence:":"Obere Schranke:","max:":"Max.:","mean \xb1 \u03c3:":"Mittelwert \xb1 \u03c3:","mean:":"Mittelwert:","median:":"Median:","min:":"Min.:","Turntable rotation":"Drehscheibenorbit","Toggle Spike Lines":"Bezugslinien an-/abschalten","open:":"Er\xf6ffnung:","high:":"H\xf6chstkurs:","low:":"Tiefstkurs:","Toggle show closest data on hover":"Anzeige der n\xe4hesten Daten an-/abschalten","incoming flow count:":"Anzahl eingehender Verbindungen:","outgoing flow count:":"Anzahl ausgehender Verbindungen:","kde:":"Dichte:"},format:{days:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"],shortDays:["So","Mo","Di","Mi","Do","Fr","Sa"],months:["Januar","Februar","M\xe4rz","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],shortMonths:["Jan","Feb","M\xe4r","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],date:"%d.%m.%Y",decimal:",",thousands:"."}};"undefined"==typeof Plotly?(window.PlotlyLocales=window.PlotlyLocales||[],window.PlotlyLocales.push(locale)):Plotly.register(locale);
|
||||
Vendored
+8
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user