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:
2026-09-26 10:21:07 +02:00
co-authored by Claude Opus 5.5
commit 77a594c3af
12 changed files with 704 additions and 0 deletions
+302
View File
@@ -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();
});
+81
View File
@@ -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.',
},
};
+57
View File
@@ -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>
+64
View File
@@ -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; }
+1
View File
@@ -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);
+8
View File
File diff suppressed because one or more lines are too long