/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 export function contrastOk(hex) { if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false const lin = (i) => { const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 } const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2) return 1.05 / (L + 0.05) >= 4.5 } export const FALLBACK_COLOR = '#111111' /** Farbe der beiden Banner-Knoepfe: die Akzentfarbe, wenn sie genug Kontrast hat, sonst #111111. */ export const bannerColor = (hex) => (contrastOk(hex) ? String(hex).toLowerCase() : FALLBACK_COLOR) export const DEVICE_LABELS = { m: 'Handy', t: 'Tablet', d: 'Computer' } export const CONTACT_LABELS = { tel: 'Telefon', mail: 'E-Mail', wa: 'WhatsApp', out: 'Externer Link' } /** Flaggen-Emoji aus ISO-3166-Alpha-2-Code ('' bei ungueltigem Code). */ export function flagEmoji(code) { if (!/^[A-Za-z]{2}$/.test(code || '')) return '' return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0))) } let regionNames /** "DE" => "Deutschland"; '' => "Unbekannt"; unbekannter Code => der Code selbst. */ export function countryName(code) { if (!/^[A-Za-z]{2}$/.test(code || '')) return 'Unbekannt' try { regionNames ||= new Intl.DisplayNames('de', { type: 'region' }) return regionNames.of(code.toUpperCase()) || code.toUpperCase() } catch { return code.toUpperCase() } } /** Herkunft: '' => "Direkt". */ export const refLabel = (key) => key || 'Direkt' /** Hoehe der Skala: naechste "runde" Zahl (1, 2, 5 x 10^n) ueber dem Maximum. */ export function niceMax(max) { if (!(max > 0)) return 1 const pow = 10 ** Math.floor(Math.log10(max)) for (const m of [1, 2, 5, 10]) if (m * pow >= max) return m * pow return 10 * pow } /** "2026-10-01" => "01.10." */ export function shortDay(day) { const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(day || '') return m ? `${m[2]}.${m[1]}.` : String(day || '') } /** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */ export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) { const data = Array.isArray(series) ? series : [] const innerW = width - pad.l - pad.r const innerH = height - pad.t - pad.b const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0))) const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2) const y = (v) => pad.t + innerH - (innerH * v) / top const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 })) const step = Math.max(1, Math.ceil(data.length / maxLabels)) const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) })) const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) })) const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ') const baseY = round(pad.t + innerH) const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : '' return { width, height, pad, top, points, xLabels, yTicks, line, area } } function round(n) { return Math.round(n * 10) / 10 } /** Zeilen fuer eine Balkenliste: [{label, count, pct}] mit pct relativ zum groessten Wert (0-100). */ export function barRows(items, labelOf) { const list = (Array.isArray(items) ? items : []).map((i) => ({ key: i.key ?? '', count: Number(i.count) || 0 })) const max = Math.max(1, ...list.map((i) => i.count)) return list.map((i) => ({ key: i.key, label: labelOf(i.key), count: i.count, pct: Math.round((i.count / max) * 100) })) }