// chart-renderer-core.js — Generischer wiederverwendbarer Chart-Rendering-Kern
import { smoothPath } from 'charts';
import { detectGapsHybrid } from 'gap-utils';
import { gapOptionsForMode, stats } from 'chart-data-utils';
import { ensureTooltip, placeTooltipNearCursor, makeGuide } from 'chart-dom-utils';
/**
* Intelligente Tick-Berechnung mit adaptiven Schritten
* @param {number} min - Minimum
* @param {number} max - Maximum
* @returns {number[]} Tick-Werte
*/
function makeSmartTicks(min, max) {
if (typeof min !== 'number' || typeof max !== 'number') return [];
const range = max - min;
if (range <= 0) return [min];
const targetTicks = 10; // Immer 10 Ticks (min + 8 Zwischenticks + max)
const step = range / (targetTicks - 1); // 9 gleiche Schritte
const ticks = [];
for (let i = 0; i < targetTicks; i++) {
const value = min + step * i;
// Auf 1 Dezimalstelle runden
ticks.push(Math.round(value * 10) / 10);
}
// WICHTIG: Ersten und letzten Tick durch EXAKTE Min/Max-Werte ersetzen
ticks[0] = Math.round(min * 10) / 10;
ticks[targetTicks - 1] = Math.round(max * 10) / 10;
return ticks;
}
/**
* Formatiert Tick-Werte einheitlich (wenn mind. 1 Dezimalstelle vorhanden, dann alle mit gleicher Anzahl)
* @param {number[]} ticks - Tick-Werte
* @returns {string[]} Formatierte Ticks
*/
function formatTicks(ticks) {
if (!ticks.length) return [];
// Prüfe, ob mindestens ein Tick eine Dezimalstelle hat
const hasDecimals = ticks.some((v) => v % 1 !== 0);
if (hasDecimals) {
// Ermittle max. Dezimalstellen (1 oder 2)
const maxDecimals = Math.max(
...ticks.map((v) => {
const str = v.toString();
const decPart = str.split('.')[1];
return decPart ? decPart.length : 0;
})
);
// Formatiere alle mit gleicher Anzahl Dezimalstellen
return ticks.map((v) => v.toFixed(Math.min(maxDecimals, 1)));
}
// Alle ganzzahlig
return ticks.map((v) => v.toString());
}
/**
* Generischer Chart-Renderer
* @param {Object} config - Konfiguration
* @param {HTMLElement} config.mount - Mount-Element
* @param {Array} config.series - Datenpunkte [{t, in, out}, ...]
* @param {Object} config.win - Zeitfenster {start, end}
* @param {Object} config.scaffold - Scaffold-Objekt {body, tooltip, legend, legendGaps}
* @param {Object} config.style - Style-Konfiguration
* @param {string} config.style.chartClass - CSS-Klasse für SVG (z.B. 'temp-chart')
* @param {string} config.style.unit - Einheit (z.B. '°C' oder '%')
* @param {string} config.style.colorOut - Farbe für Außen-Serie
* @param {string} config.style.colorIn - Farbe für Innen-Serie
* @param {Object} config.options - Rendering-Optionen
* @param {string} config.options.mode - Modus ('auto', 'day', 'week', 'month')
* @param {boolean} config.options.gapsEnabled - Lücken-Visualisierung
* @param {number} config.options.padTop - Padding oben
* @param {number} config.options.padBottom - Padding unten
*/
export function renderGenericChart({ mount, series, win, scaffold, style = {}, options = {} }) {
const {
chartClass = 'generic-chart',
unit = '',
colorOut = '#b2182b',
colorIn = '#2166ac',
labelOut = 'Außen',
labelIn = 'Innen',
showLabelsInTooltip = true, // Labels im Tooltip anzeigen (false für Pressure-Chart)
} = style;
const { mode = 'auto', gapsEnabled = false, padTop = 2, padBottom = 0 } = options;
const { body, tooltip, legend, legendGaps } = scaffold;
if (legendGaps) {
legendGaps.classList.toggle('d-none', !gapsEnabled);
legendGaps.style.display = gapsEnabled ? '' : 'none';
}
// Dimensionen
const isFullscreen =
document.fullscreenElement === mount || document.webkitFullscreenElement === mount;
let H;
if (isFullscreen) {
// Im Vollbild: Verfügbare Höhe = Viewport - Header - Legende
const header = mount.querySelector('.card-header');
const legend = mount.querySelector('.legend');
const headerHeight = header?.offsetHeight || 0;
const legendHeight = legend?.offsetHeight || 0;
H = window.innerHeight - headerHeight - legendHeight;
} else {
// Normal: Body-Höhe
H = Math.max(300, body.clientHeight || mount.clientHeight || 300);
}
const W = body.clientWidth || mount.clientWidth || 360;
const fontSize = 12; // Schriftgröße in px
let L = 48, // Wird später nach Tick-Berechnung aktualisiert
T = fontSize * 2, // 2× Schriftgröße = 24px
B = fontSize * 3; // Unten Platz für X-Achse
// Daten validieren
if (!series || !series.length) {
body.innerHTML = '<div class="text-body-secondary small px-2">Keine Daten</div>';
return;
}
// Zeitfenster
const xs = series.map((d) => d.t);
const t0 = win?.start ?? xs[0];
const t1 = win?.end ?? xs[xs.length - 1];
// Werte extrahieren
const inVals = series.map((p) => p.in).filter(Number.isFinite);
const outVals = series.map((p) => p.out).filter(Number.isFinite);
const sIn = stats(inVals);
const sOut = stats(outVals);
// Zwei-Achsen-Logik
let twoAxes = false;
let yMin, yMax, yMinIn, yMaxIn, yMinOut, yMaxOut;
if (
Number.isFinite(sIn.min) &&
Number.isFinite(sIn.max) &&
Number.isFinite(sOut.min) &&
Number.isFinite(sOut.max) &&
((sIn.max > sOut.max && sIn.min > sOut.max) || (sOut.max > sIn.max && sOut.min > sIn.max))
) {
twoAxes = true;
// KEIN Padding - nutzen feste Margins (T, B)
yMinIn = sIn.min;
yMaxIn = sIn.max;
yMinOut = sOut.min;
yMaxOut = sOut.max;
} else {
const allVals = [...inVals, ...outVals].filter(Number.isFinite);
yMin = Math.min(...allVals);
yMax = Math.max(...allVals);
if (!Number.isFinite(yMin) || !Number.isFinite(yMax) || yMin === yMax) {
yMin = 0;
yMax = 100;
}
// KEIN Padding - nutzen feste Margins (T, B)
}
// Dynamische Berechnung der linken Margin basierend auf erwarteter Y-Tick-Breite
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '12px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
let maxTickWidth = 0;
if (twoAxes) {
// Schätze Ticks für Out-Achse
const tempTicks = makeSmartTicks(sOut.min, sOut.max);
const tempFormatted = formatTicks(tempTicks);
tempFormatted.forEach((label) => {
const width = ctx.measureText(label + unit).width;
maxTickWidth = Math.max(maxTickWidth, width);
});
} else {
// Schätze Ticks für gemeinsame Achse
const allVals = [...inVals, ...outVals].filter(Number.isFinite);
const tempMin = Math.min(...allVals);
const tempMax = Math.max(...allVals);
const tempTicks = makeSmartTicks(tempMin, tempMax);
const tempFormatted = formatTicks(tempTicks);
tempFormatted.forEach((label) => {
const width = ctx.measureText(label + unit).width;
maxTickWidth = Math.max(maxTickWidth, width);
});
}
// L = Tick-Text-Breite + 8px Gap + 6px Tick-Linie
L = Math.max(48, Math.ceil(maxTickWidth) + 14);
// R = Rechte Margin (nur bei zwei Achsen nötig)
const R = twoAxes ? 48 : 8;
// Skalen-Funktionen
const innerW = W - L - R;
const innerH = H - T - B;
function sx(t) {
return L + ((t - t0) / Math.max(1, t1 - t0)) * innerW;
}
function sy(v) {
return T + innerH - ((v - yMin) / (yMax - yMin)) * innerH;
}
function syIn(v) {
return T + innerH - ((v - yMinIn) / Math.max(1e-6, yMaxIn - yMinIn)) * innerH;
}
function syOut(v) {
return T + innerH - ((v - yMinOut) / Math.max(1e-6, yMaxOut - yMinOut)) * innerH;
}
// Guides (mit temporären Skalen-Funktionen - werden später neu berechnet)
let guides = [];
if (twoAxes) {
if (Number.isFinite(sOut.min))
guides.push(makeGuide(sOut.min, 'out-min', 'min', sOut.min, syOut, 'L', L, R, W));
if (Number.isFinite(sOut.max))
guides.push(makeGuide(sOut.max, 'out-max', 'max', sOut.max, syOut, 'L', L, R, W));
if (Number.isFinite(sOut.avg))
guides.push(makeGuide(sOut.avg, 'out-avg', 'Ø', sOut.avg, syOut, 'L', L, R, W));
if (Number.isFinite(sIn.min))
guides.push(makeGuide(sIn.min, 'in-min', 'min', sIn.min, syIn, 'R', L, R, W));
if (Number.isFinite(sIn.max))
guides.push(makeGuide(sIn.max, 'in-max', 'max', sIn.max, syIn, 'R', L, R, W));
if (Number.isFinite(sIn.avg))
guides.push(makeGuide(sIn.avg, 'in-avg', 'Ø', sIn.avg, syIn, 'R', L, R, W));
} else {
if (Number.isFinite(sOut.min))
guides.push(makeGuide(sOut.min, 'out-min', 'min', sOut.min, sy, 'L', L, R, W));
if (Number.isFinite(sOut.max))
guides.push(makeGuide(sOut.max, 'out-max', 'max', sOut.max, sy, 'L', L, R, W));
if (Number.isFinite(sOut.avg))
guides.push(makeGuide(sOut.avg, 'out-avg', 'Ø', sOut.avg, sy, 'L', L, R, W));
if (Number.isFinite(sIn.min))
guides.push(makeGuide(sIn.min, 'in-min', 'min', sIn.min, sy, 'R', L, R, W));
if (Number.isFinite(sIn.max))
guides.push(makeGuide(sIn.max, 'in-max', 'max', sIn.max, sy, 'R', L, R, W));
if (Number.isFinite(sIn.avg))
guides.push(makeGuide(sIn.avg, 'in-avg', 'Ø', sIn.avg, sy, 'R', L, R, W));
}
const guideLines = guides.map((p) => p.line).join('');
const guideLabels = guides.map((p) => p.label).join('');
// Legende aktualisieren
if (legend) {
const inLabel = legend.querySelector('[data-ser="in"] .legend-label');
const outLabel = legend.querySelector('[data-ser="out"] .legend-label');
const fmtC = (v) => (Number.isFinite(v) ? v.toFixed(1) : '–');
const fmtRange = (min, max) =>
Number.isFinite(min) && Number.isFinite(max) ? `${fmtC(min)}–${fmtC(max)}${unit}` : '–';
const fmtLR = (labelText) =>
`<span class="badge text-bg-secondary small"><span class="small">${labelText}</span></span>`;
if (inLabel) {
inLabel.innerHTML = `${labelIn} <span class="small text-body-tertiary"><span class="small">${fmtRange(
sIn.min,
sIn.max
)}</span></span> ${twoAxes ? fmtLR('R') : ''}`;
}
if (outLabel) {
outLabel.innerHTML = `${labelOut} <span class="small text-body-tertiary"><span class="small">${fmtRange(
sOut.min,
sOut.max
)}</span></span> ${twoAxes ? fmtLR('L') : ''}`;
}
}
// Gap-Erkennung
let gaps = [];
let gapsSvg = '';
if (gapsEnabled && series.length > 1) {
const gapOpts = gapOptionsForMode(mode);
gaps = detectGapsHybrid(series, gapOpts);
if (gaps.length) {
const elems = [];
for (const g of gaps) {
const x1 = sx(g.from);
const x2 = sx(g.to);
const w = Math.max(2, x2 - x1);
elems.push(
`<rect x="${x1}" y="${T}" width="${w}" height="${innerH}" class="gap-band" data-from="${g.from}" data-to="${g.to}" data-missed="${g.missed}"></rect>`
);
}
gapsSvg = `<g class="gaps">${elems.join('')}</g>`;
}
}
// Linien-Segmente (bei Gaps unterbrechen)
function isInGap(t) {
if (!gapsEnabled || !gaps.length) return false;
return gaps.some((g) => t > g.from && t < g.to);
}
function buildSegments(key, syFn) {
const segs = [];
let cur = [];
for (const p of series) {
const v = p[key];
if (!Number.isFinite(v)) continue;
if (isInGap(p.t)) {
if (cur.length >= 2) segs.push(cur);
cur = [];
continue;
}
cur.push([sx(p.t), syFn(v)]);
}
if (cur.length >= 2) segs.push(cur);
return segs;
}
const segsOut = gapsEnabled
? buildSegments('out', twoAxes ? syOut : sy)
: [
series
.filter((p) => Number.isFinite(p.out))
.map((p) => [sx(p.t), twoAxes ? syOut(p.out) : sy(p.out)]),
];
const segsIn = gapsEnabled
? buildSegments('in', twoAxes ? syIn : sy)
: [
series
.filter((p) => Number.isFinite(p.in))
.map((p) => [sx(p.t), twoAxes ? syIn(p.in) : sy(p.in)]),
];
// Punkte
const dots = [];
for (const p of series) {
// Punkte-Größe: Bei Lücken immer klein (0.8), Auto-Modus mittel (1.2), andere Modi normal (2)
const dR = gapsEnabled ? 0.8 : mode === 'auto' ? 1.2 : 2;
const inGap = gapsEnabled ? !isInGap(p.t) : true;
if (inGap && Number.isFinite(p.in)) {
const cx = sx(p.t);
const cy = twoAxes ? syIn(p.in) : sy(p.in);
if (Number.isFinite(cx) && Number.isFinite(cy)) {
dots.push(`<circle cx="${cx}" cy="${cy}" r="${dR}" class="dot dot-in" />`);
}
}
if (inGap && Number.isFinite(p.out)) {
const cx = sx(p.t);
const cy = twoAxes ? syOut(p.out) : sy(p.out);
if (Number.isFinite(cx) && Number.isFinite(cy)) {
dots.push(`<circle cx="${cx}" cy="${cy}" r="${dR}" class="dot dot-out" />`);
}
}
}
// Y-Achsen-Ticks berechnen (für Grid-Ausrichtung)
let ticksOut, ticksIn, ticks, dataMin, dataMax;
if (twoAxes) {
ticksOut = makeSmartTicks(sOut.min, sOut.max);
ticksIn = makeSmartTicks(sIn.min, sIn.max);
} else {
const allVals = [...inVals, ...outVals].filter(Number.isFinite);
dataMin = Math.min(...allVals);
dataMax = Math.max(...allVals);
ticks = makeSmartTicks(dataMin, dataMax);
}
// Y-Achsen-Ticks rendern
let yTicks = '';
if (twoAxes) {
const ticksOutFormatted = formatTicks(ticksOut);
ticksOut.forEach((v, i) => {
const y = syOut(v);
yTicks += `<g class="ytick ytick-left"><line x1="${
L - 6
}" y1="${y}" x2="${L}" y2="${y}"></line><text x="${
L - 8
}" y="${y}" text-anchor="end" dominant-baseline="middle">${
ticksOutFormatted[i]
}${unit}</text></g>`;
});
const ticksInFormatted = formatTicks(ticksIn);
ticksIn.forEach((v, i) => {
const y = syIn(v);
yTicks += `<g class="ytick ytick-right"><line x1="${W - R}" y1="${y}" x2="${
W - R + 6
}" y2="${y}"></line><text x="${
W - R + 8
}" y="${y}" text-anchor="start" dominant-baseline="middle">${
ticksInFormatted[i]
}${unit}</text></g>`;
});
} else {
const ticksFormatted = formatTicks(ticks);
ticks.forEach((v, i) => {
const y = sy(v);
yTicks += `<g class="ytick"><line x1="${
L - 6
}" y1="${y}" x2="${L}" y2="${y}"></line><text x="${
L - 8
}" y="${y}" text-anchor="end" dominant-baseline="middle">${
ticksFormatted[i]
}${unit}</text></g>`;
});
}
// X-Achsen-Ticks
const xTicks = [];
const spanMs = t1 - t0;
const tickCount = spanMs <= 26 * 3600 * 1000 ? 4 : 6;
for (let i = 0; i <= tickCount; i++) {
const t = t0 + (i / tickCount) * (t1 - t0);
const d = new Date(t);
const timeOnly = spanMs <= 26 * 3600 * 1000;
const lab = timeOnly
? d.toLocaleString('de-AT', { hour: '2-digit', minute: '2-digit' })
: d.toLocaleString('de-AT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
let x = sx(t);
const isFirst = i === 0,
isLast = i === tickCount;
x = Math.max(L + 2, Math.min(W - R - 2, x));
const anchor = isFirst ? 'start' : isLast ? 'end' : 'middle';
xTicks.push(
`<g class="tick"><line x1="${x}" y1="${H - B}" x2="${x}" y2="${
H - B + 6
}"></line><text x="${x}" y="${H - B + 18}" text-anchor="${anchor}">${lab}</text></g>`
);
}
// SVG erstellen
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('class', twoAxes ? `${chartClass} two-axes` : chartClass);
svg.setAttribute('width', String(W));
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.setAttribute('preserveAspectRatio', 'none');
svg.style.display = 'block';
svg.setAttribute('height', String(H));
svg.style.setProperty('height', H + 'px', 'important');
const svgContent = `
<style>
.${chartClass} { font: 12px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; overflow: visible; }
.${chartClass} .ytick text { fill: #888; opacity: .85; }
.${chartClass} .tick line { stroke: #888; opacity: .2; }
.${chartClass} .grid { stroke: #888; opacity: .3; }
.${chartClass} path.line-in { stroke: ${colorIn}; fill: none; stroke-width: 2.25; }
.${chartClass} path.line-out { stroke: ${colorOut}; fill: none; stroke-width: 2.25; }
.${chartClass} .dot-in { fill: ${colorIn}; opacity: .9; }
.${chartClass} .dot-out { fill: ${colorOut}; opacity: .9; }
.${chartClass} .g-in-min line, .${chartClass} .g-in-max line, .${chartClass} .g-in-avg line { stroke: ${colorIn}; opacity: .22; }
.${chartClass} .g-out-min line, .${chartClass} .g-out-max line, .${chartClass} .g-out-avg line { stroke: ${colorOut}; opacity: .22; }
.${chartClass} .guide-label { paint-order: stroke fill; stroke: rgba(var(--bs-body-bg-rgb),.5); stroke-width: 10px; }
.${chartClass} .guide-labels text.g-in-min, .${chartClass} .guide-labels text.g-in-max, .${chartClass} .guide-labels text.g-in-avg { fill: ${colorIn}; opacity: .85; }
.${chartClass} .guide-labels text.g-out-min, .${chartClass} .guide-labels text.g-out-max, .${chartClass} .guide-labels text.g-out-avg { fill: ${colorOut}; opacity: .85; }
.${chartClass} .gap-band { fill: rgba(220,53,69,.18); stroke: rgba(220,53,69,.35); stroke-width: 1; cursor: help; }
.${chartClass}.two-axes .ytick-left text { fill:${colorOut}; opacity:.85 }
.${chartClass}.two-axes .ytick-right text { fill:${colorIn}; opacity:.85 }
</style>
<g class="grid">${(() => {
// Grid-Linien nur bei 4. und 7. Tick (Index 3 und 6)
const yGrid = [];
const gridIndices = [3, 6]; // 4. und 7. Tick
if (twoAxes) {
// Bei zwei Achsen: Grid basierend auf linker Achse (Out)
gridIndices.forEach((idx) => {
if (ticksOut && ticksOut[idx] !== undefined) {
const y = syOut(ticksOut[idx]);
if (Number.isFinite(y)) {
yGrid.push(`<line x1="${L}" y1="${y}" x2="${W - R}" y2="${y}" class="grid"/>`);
}
}
});
} else {
// Bei einer Achse: Grid basierend auf gemeinsamen Ticks
gridIndices.forEach((idx) => {
if (ticks && ticks[idx] !== undefined) {
const y = sy(ticks[idx]);
if (Number.isFinite(y)) {
yGrid.push(`<line x1="${L}" y1="${y}" x2="${W - R}" y2="${y}" class="grid"/>`);
}
}
});
}
return yGrid.join('');
})()}</g>
<g class="axis axis-y">${yTicks}</g>
<g class="guide-lines">${guideLines}</g>
<g class="lines">
${segsOut
.map((pts) =>
pts.length >= 2 ? `<path class="line-out" d="${smoothPath(pts)}"></path>` : ''
)
.join('')}
${segsIn
.map((pts) =>
pts.length >= 2 ? `<path class="line-in" d="${smoothPath(pts)}"></path>` : ''
)
.join('')}
</g>
<g class="dots">${dots.join('')}</g>
<g class="guide-labels">${guideLabels}</g>
${gapsSvg}
<g class="axis axis-x">${xTicks.join('')}</g>
`;
svg.innerHTML = svgContent;
body.replaceChildren(svg);
// Hover-Logik
setupHoverInteraction({
svg,
mount,
series,
tooltip,
sx,
sy: twoAxes ? null : sy,
syIn: twoAxes ? syIn : null,
syOut: twoAxes ? syOut : null,
twoAxes,
t0,
t1,
T,
H,
B,
L,
W,
unit,
colorIn,
colorOut,
labelIn,
labelOut,
showLabelsInTooltip,
gaps,
gapsEnabled,
});
}
/**
* Hover-Interaktion für Chart
*/
function setupHoverInteraction({
svg,
mount,
series,
tooltip,
sx,
sy,
syIn,
syOut,
twoAxes,
t0,
t1,
T,
H,
B,
L,
W,
unit,
colorIn,
colorOut,
labelIn,
labelOut,
showLabelsInTooltip,
gaps,
gapsEnabled,
}) {
const hl =
svg.querySelector('.hover-line') ||
document.createElementNS('http://www.w3.org/2000/svg', 'line');
hl.setAttribute('class', 'hover-line');
hl.setAttribute('stroke', 'currentColor');
hl.setAttribute('opacity', '.25');
hl.setAttribute('stroke-width', '1');
hl.style.display = 'none';
svg.appendChild(hl);
function nearestIndex(t) {
if (!series.length) return -1;
let lo = 0,
hi = series.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
series[mid].t < t ? (lo = mid) : (hi = mid);
}
const clo = Math.abs(series[lo].t - t);
const chi = Math.abs(series[hi].t - t);
return clo <= chi ? lo : hi;
}
function onMove(evt) {
const rect = svg.getBoundingClientRect();
const x = evt.clientX - rect.left;
const ratio = (x - L) / (W - L - 48);
const t = t0 + Math.max(0, Math.min(1, ratio)) * (t1 - t0);
const idx = nearestIndex(t);
if (idx < 0 || !series[idx]) {
hl.style.display = 'none';
return;
}
const p = series[idx];
const cx = sx(p.t);
if (!Number.isFinite(cx)) {
hl.style.display = 'none';
return;
}
hl.setAttribute('x1', cx);
hl.setAttribute('x2', cx);
hl.setAttribute('y1', T);
hl.setAttribute('y2', H - B);
hl.style.display = '';
function formatTs(ts) {
return new Date(ts).toLocaleString('de-AT', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function formatVal(v) {
return Number.isFinite(v) ? v.toFixed(1) + unit : '–';
}
// Zeige nur Zeilen für vorhandene Werte (Single-Value-Charts wie Pressure)
const hasIn = Number.isFinite(p.in);
const outLabel = showLabelsInTooltip ? `${labelOut}: ` : '';
const inLabel = showLabelsInTooltip ? `${labelIn}: ` : '';
const outLine = `<div><span style="display:inline-block;width:8px;height:8px;border-radius:999px;background:${colorOut};margin-right:6px"></span>${outLabel}${formatVal(
p.out
)}</div>`;
const inLine = hasIn
? `<div><span style="display:inline-block;width:8px;height:8px;border-radius:999px;background:${colorIn};margin-right:6px"></span>${inLabel}${formatVal(
p.in
)}</div>`
: '';
const tt = `<div><strong>${formatTs(p.t)}</strong></div>${outLine}${inLine}`;
tooltip.innerHTML = tt;
tooltip.style.display = 'block';
placeTooltipNearCursor({ tooltip, mount, evt, offset: 12 });
}
function onLeave() {
hl.style.display = 'none';
tooltip.style.display = 'none';
}
svg.addEventListener('mousemove', function (evt) {
const gapEl = evt.target && evt.target.closest ? evt.target.closest('.gap-band') : null;
if (gapEl) {
const from = +gapEl.dataset.from;
const to = +gapEl.dataset.to;
const dur = to - from;
const fmtTime = (ms) => {
const d = new Date(ms),
p = (n) => String(n).padStart(2, '0');
return `${p(d.getDate())}.${p(d.getMonth() + 1)}., ${p(d.getHours())}:${p(d.getMinutes())}`;
};
const fmtDur = (ms) => {
const m = Math.round(ms / 60000);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60),
r = m % 60;
return r ? `${h} h ${r} min` : `${h} h`;
};
hl.style.display = 'none';
tooltip.style.display = 'block';
tooltip.innerHTML = `<div><div><strong>Lücke</strong> · ${fmtDur(dur)}</div><div>${fmtTime(
from
)} – ${fmtTime(to)}</div></div>`;
tooltip.style.position = 'fixed';
tooltip.style.left = evt.clientX + 12 + 'px';
tooltip.style.top = evt.clientY + 12 + 'px';
return;
}
onMove(evt);
});
svg.addEventListener('mouseleave', onLeave);
}