Files
Webklar-Kundenbereich/public/analyse-view.js
T

104 lines
5.7 KiB
JavaScript

/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in 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) }))
}
/** Banner-Text der Portal-Vorschau; muss mit BANNER_TEXT in analytics/client/core.js uebereinstimmen (Paritaetstest). */
export const BANNER_PREVIEW_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '
/** Zeilen der Verantwortlichen-Vorschau: Firma (sonst Name), Anschrift falls hinterlegt, E-Mail. Leere Angaben entfallen. */
export function controllerLines(controller) {
const c = controller || {}
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
}
/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */
export const analyseOf = (project) => project.analyse || { comingSoon: true }
export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
export function pickAnalyseProject(projects) {
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
}
// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js)
export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }