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