Source: development/js/chart-renderer-core.js

// 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);
}