Analyse: Portal-Kachel und Auswertung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc937b0d1e
commit
5a6fbc7328
6 files changed
+483
-4
No files matched your search
@@ -0,0 +1,83 @@
|
||||
/* 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) }))
|
||||
}
|
||||
Reference in new issue
Block a user