84 lines
4.0 KiB
JavaScript
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) }))
|
|
}
|