Source: js/chart-core.js

// chart-core.js
//
// Enthält mathematische und SVG-Basisfunktionen für alle XY-Charts.
// Fokus: Achsen, Grid, Ticks, Daten-Buckets, Aggregation, Padding, Hilfsfunktionen.
// Wird von allen Chart-Modulen als universelles Fundament genutzt.

/**
 * Padding-Konstanten für Charts.
 * @type {{l: number, r: number, t: number, b: number}}
 */
export const PAD = { l: 48, r: 16, t: 12, b: 28 };

/**
 * Sorgt dafür, dass ein Card-Container existiert und gibt die wichtigsten DOM-Elemente zurück.
 * @param {string} mountSel - CSS-Selektor für das Mount-Element
 * @param {string} title - Titel für die Card
 * @returns {{mount: Element, card: Element, body: Element, legend: Element}}
 */
export function ensureCard(mountSel, title) {
  const mount = document.querySelector(mountSel);
  if (!mount) throw new Error(`mount not found: ${mountSel}`);

  let card = mount.querySelector(':scope > .card');
  if (!card) {
    card = document.createElement('div');
    card.className = 'card';
    card.innerHTML = `
			<div class="card-header d-flex justify-content-between align-items-center">
				<div class="fw-semibold">${title}</div>
				<div class="chart-legend small text-body-secondary"></div>
			</div>
			<div class="card-body">
				<div class="chart-body"></div>
			</div>`;
    mount.append(card);
  }
  const body = card.querySelector('.chart-body');
  const legend = card.querySelector('.chart-legend');
  if (!body || !legend) throw new Error('Card scaffold fehlerhaft erzeugt');
  if (typeof mountSel !== 'string' || !mountSel)
    throw new Error('mountSel muss ein nicht-leerer String sein');
  if (typeof title !== 'string') throw new Error('title muss ein String sein');
  return { mount, card, body, legend };
}

/**
 * Entfernt alle Kinder eines Elements.
 * @deprecated Ungenutzt – Kandidat für Entfernung
 * @param {Element} el
 */
export function clear(el) {
  while (el.firstChild) el.removeChild(el.firstChild);
  if (!el || typeof el.removeChild !== 'function') return;
}

/**
 * Erstellt ein SVG-Element im Ziel-Element.
 * @param {Element} el
 * @param {number} [height=260]
 * @returns {SVGSVGElement}
 */
export function svgIn(el, height = 260) {
  if (!el || typeof el.append !== 'function')
    throw new Error('svgIn: Ziel-Element fehlt oder ist ungültig');
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('width', '100%');
  svg.setAttribute('height', String(height));
  el.append(svg);
  return svg;
}

// Bucket-Größe je Modus (wie produktiv)
/**
 * Berechnet die Bucket-Größe (Millisekunden) je Modus.
 * @param {{mode: string, gapsEnabled: boolean}}
 * @returns {number}
 */
export function computeBucketMs({ mode, gapsEnabled }) {
  if (gapsEnabled || mode === 'auto') return 60_000;
  if (mode === 'day') return 3_600_000;
  if (mode === 'week') return 86_400_000;
  if (!mode) throw new Error('computeBucketMs: mode fehlt');
  return 7 * 86_400_000; // month
}

// Dynamisches Domain-Padding (wie produktiv)
/**
 * Dynamisches Domain-Padding.
 * @param {number} lo
 * @param {number} hi
 * @param {Object} [opts] - Optionale Einstellungen
 * @param {number} [opts.minPad=0.4] - Mindest-Padding
 * @param {number} [opts.frac=0.06] - Prozentuales Padding relativ zur Spannweite
 * @returns {Array.<number>} Zweier-Array [lo, hi] inkl. Padding
 */
export function padDomainDyn(lo, hi, { minPad = 0.4, frac = 0.06 } = {}) {
  if (typeof lo !== 'number' || typeof hi !== 'number')
    throw new Error('padDomainDyn: lo/hi müssen Zahlen sein');
  const span = hi - lo;
  if (span === 0) return [lo - minPad, hi + minPad];
  const pad = Math.max(minPad, span * frac);
  return [lo - pad, hi + pad];
}

// Intelligente Schrittweite für Ticks (wie produktiv)
/**
 * Erzeugt eine Tick-Liste für Achsen.
 * @param {number} min
 * @param {number} max
 * @returns {number[]}
 */
export function makeTicks(min, max) {
  if (typeof min !== 'number' || typeof max !== 'number') return [];
  const lo = Math.floor(min),
    hi = Math.ceil(max);
  if (hi < lo) return [];
  const spanI = hi - lo;
  const step = spanI > 12 ? 2 : 1;
  const out = [];
  for (let v = lo; v <= hi; v += step) out.push(v);
  return out;
}

// Clip & Bin (Aggregationshülle für Chart-Daten)
/**
 * Aggregiert und binned Chart-Daten.
 * @param {Object} opts
 * @param {Array} opts.rows
 * @param {number} opts.start
 * @param {number} opts.end
 * @param {string} opts.mode
 * @param {boolean} opts.gapsEnabled
 * @param {Function} opts.binFn
 * @param {number} [opts.padFactor]
 * @returns {Array}
 */
export function clipAndBinSeries({
  rows, // schon in internes Format gemappt: {t,in,out}
  start,
  end,
  mode,
  gapsEnabled,
  binFn, // (series, mode, start, end) => series
  padFactor = 1.0, // optional, Standard wie jetzt
}) {
  if (!Array.isArray(rows)) throw new Error('clipAndBinSeries: rows muss ein Array sein');
  if (typeof start !== 'number' || typeof end !== 'number')
    throw new Error('clipAndBinSeries: start/end müssen Zahlen sein');
  if (typeof binFn !== 'function') throw new Error('clipAndBinSeries: binFn muss Funktion sein');
  const bucketMs = computeBucketMs({ mode, gapsEnabled });
  const half = Math.floor(bucketMs / 2);
  const pad = Math.floor(bucketMs * padFactor);

  // 1) großzügiger Vor-Clip (damit Rand-Buckets mitkommen)
  let base = rows.filter((p) => p.t >= start - pad && p.t <= end + pad);

  // 2) Aggregation nur ohne Gaps (mit Gaps Einzelpunkte)
  let binned = gapsEnabled ? base : binFn(base, mode, start - half, end + half);

  // 3) Zeitstempel fürs Zeichnen ins echte Fenster zurückklemmen
  binned = binned.map((p) => ({ ...p, t: Math.min(end, Math.max(start, p.t)) }));

  // 4) Edge-Snap & Value-Fill: Stelle sicher, dass Start/Ende vorhanden sind UND Werte tragen
  const dist = (a, b) => Math.abs(a - b);
  if (binned.length) {
    // Helper: nächsten gültigen Wert je Key (in/out) finden
    const pickAtOrBefore = (arr, t) => {
      let vin = null,
        vout = null;
      for (let i = arr.length - 1; i >= 0; i--) {
        const p = arr[i];
        if (p.t > t) continue;
        if (vout === null && Number.isFinite(p.out)) vout = p.out;
        if (vin === null && Number.isFinite(p.in)) vin = p.in;
        if (vin !== null && vout !== null) break;
      }
      return { in: vin, out: vout };
    };
    const pickAtOrAfter = (arr, t) => {
      let vin = null,
        vout = null;
      for (let i = 0; i < arr.length; i++) {
        const p = arr[i];
        if (p.t < t) continue;
        if (vout === null && Number.isFinite(p.out)) vout = p.out;
        if (vin === null && Number.isFinite(p.in)) vin = p.in;
        if (vin !== null && vout !== null) break;
      }
      return { in: vin, out: vout };
    };

    // Rechts (Ende)
    const endIdx = binned.findIndex((p) => p.t === end);
    const fillEnd = pickAtOrBefore(base, end);
    if (endIdx >= 0) {
      const cur = binned[endIdx];
      const merged = {
        ...cur,
        in: Number.isFinite(cur.in) ? cur.in : fillEnd.in,
        out: Number.isFinite(cur.out) ? cur.out : fillEnd.out,
        t: end,
      };
      // Nur übernehmen, wenn mindestens ein Wert vorhanden ist
      if (Number.isFinite(merged.in) || Number.isFinite(merged.out)) binned[endIdx] = merged;
    } else {
      if (Number.isFinite(fillEnd.in) || Number.isFinite(fillEnd.out)) {
        binned.push({ t: end, in: fillEnd.in ?? null, out: fillEnd.out ?? null });
      } else {
        // Fallback zum alten Verhalten: nächstgelegenen Punkt <= end verwenden, wenn deutlich entfernt
        const last = binned[binned.length - 1];
        if (end - last.t > half * 0.9) {
          const nearRight = base.reduce(
            (best, p) =>
              p.t > end ? best : !best || dist(p.t, end) < dist(best.t, end) ? p : best,
            null
          );
          if (nearRight) binned.push({ ...nearRight, t: end });
        }
      }
    }

    // Links (Start)
    const startIdx = binned.findIndex((p) => p.t === start);
    const fillStart = pickAtOrAfter(base, start);
    if (startIdx >= 0) {
      const cur = binned[startIdx];
      const merged = {
        ...cur,
        in: Number.isFinite(cur.in) ? cur.in : fillStart.in,
        out: Number.isFinite(cur.out) ? cur.out : fillStart.out,
        t: start,
      };
      if (Number.isFinite(merged.in) || Number.isFinite(merged.out)) binned[startIdx] = merged;
    } else {
      if (Number.isFinite(fillStart.in) || Number.isFinite(fillStart.out)) {
        binned.unshift({ t: start, in: fillStart.in ?? null, out: fillStart.out ?? null });
      } else {
        const first = binned[0];
        if (first.t - start > half * 0.9) {
          const nearLeft = base.reduce(
            (best, p) =>
              p.t < start ? best : !best || dist(p.t, start) < dist(best.t, start) ? p : best,
            null
          );
          if (nearLeft) binned.unshift({ ...nearLeft, t: start });
        }
      }
    }
  }

  // final sort + clamp (idempotent)
  binned.sort((a, b) => a.t - b.t);
  return binned.map((p) => ({ ...p, t: Math.min(end, Math.max(start, p.t)) }));
}

// Resize Observer für responsive Charts
export function onResize(el, cb) {
  const ro = new ResizeObserver(cb);
  ro.observe(el);
  return () => ro.disconnect();
}

// ...restlicher Code wie im Original...
/**
 * Beobachtet die Größe eines Elements und ruft Callback bei Änderung.
 * @param {Element} el
 * @param {Function} onSize
 * @returns {Function} Disconnect-Funktion
 */
export function observeSize(el, onSize) {
  if (!el || typeof el.getBoundingClientRect !== 'function') {
    throw new Error('observeSize: el muss ein gültiges DOM-Element sein');
  }
  if (typeof onSize !== 'function') {
    throw new Error('observeSize: onSize muss eine Funktion sein');
  }
  // liefert Innenmaße (ohne Padding/Border)
  const ro = new ResizeObserver(() => {
    const r = el.getBoundingClientRect();
    const width = Math.max(10, Math.floor(r.width));
    const height = Math.max(10, Math.floor(r.height));
    onSize({ width, height });
  });
  ro.observe(el);
  // initial
  const r = el.getBoundingClientRect();
  onSize({ width: Math.max(10, r.width | 0), height: Math.max(10, r.height | 0) });
  return () => ro.disconnect();
}