Files
Webklar-Kundenbereich/public/analyse-view.js
T
2026-10-01 10:24:48 +00:00

84 lines
4.0 KiB
JavaScript

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