import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react"; import { DemoZeiger, useBewegungErlaubt, useFeinerZeiger, zufall, type DemoVariante } from "../gemeinsam"; import { heatEbene, punkteInZone, type HeatPunkt, type KlickPunkt } from "../heat"; import { HEATMAP_START, KLICK_ZIELE, type HeatmapZustand } from "./ablauf"; import { FLAECHE_GROESSE, TOP_ZONE, ZONEN, ZONEN_LAYOUT, schildText, zoneNach, type Zone, type PunktRechteck } from "./zonen"; import "./heatmap.css"; /** * Die erfundene Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2) mit der * Heatmap darüber. 'desktop' wird in 1512 x 893 gesetzt (MacBook), 'handy' in 402 x 874 (iPhone, oben 59 und * unten 34 frei). Die Seite ist reine Dekoration: aria-hidden, keine fokussierbaren Elemente. * Alle Bilder sind selbst gezeichnete SVG/CSS-Grafiken, nichts wird nachgeladen. */ /** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */ type Props = { zustand?: HeatmapZustand; variante: DemoVariante }; // ── Routenbild: selbst gezeichnete Kletterwand ──────────────────────────────── type Farbe = "lime" | "orange" | "pink" | "cyan"; const FARBEN: Record = { lime: { hell: "#c6f432", dunkel: "#8aa81a" }, orange: { hell: "#ff7a1a", dunkel: "#b8500a" }, pink: { hell: "#ff4f9a", dunkel: "#b82a6b" }, cyan: { hell: "#2ad1ff", dunkel: "#0e86ad" }, }; /** Formen von Klettergriffen um den Nullpunkt (etwa 70 breit). */ const FORMEN: Record = { a: "M-30 8C-35-14-10-33 14-29C35-25 38-2 30 14C22 31-25 33-30 8Z", b: "M-35 2C-31-25 31-27 35 1C37 22 21 30 0 30C-22 30-37 23-35 2Z", c: "M-37-8Q0-24 37-8Q42 13 0 15Q-42 13-37-8Z", d: "M0-27C16-27 28-15 28 0C28 15 16 27 0 27C-16 27-28 15-28 0C-28-15-16-27 0-27Z", e: "M-25 20C-33-4-8-31 14-27C35-23 31 8 23 22C9 31-14 31-25 20Z", }; /** [x, y, Form, Größe, Drehung]; x und y als Anteil der Wand (0 bis 1). */ type Griff = readonly [number, number, string, number, number]; const ROUTEN: { farbe: Farbe; grad: string; griffe: readonly Griff[] }[] = [ { farbe: "lime", grad: "4", griffe: [ [0.14, 0.84, "b", 1.1, -8], [0.22, 0.72, "a", 0.9, 20], [0.13, 0.6, "c", 1, -15], [0.24, 0.47, "e", 0.95, 35], [0.15, 0.34, "a", 0.9, -30], [0.26, 0.22, "d", 0.9, 0], [0.18, 0.1, "b", 1.15, 10], ], }, { farbe: "orange", grad: "5", griffe: [ [0.36, 0.86, "c", 1, 10], [0.44, 0.74, "a", 1, -20], [0.34, 0.62, "b", 0.9, 15], [0.47, 0.5, "e", 1, -40], [0.38, 0.37, "d", 0.85, 0], [0.5, 0.25, "a", 0.95, 25], [0.42, 0.12, "b", 1.1, -5], ], }, { farbe: "pink", grad: "6", griffe: [ [0.6, 0.82, "a", 1, 5], [0.68, 0.71, "e", 0.9, -25], [0.57, 0.58, "c", 1.05, 20], [0.7, 0.46, "b", 1, -10], [0.61, 0.34, "a", 0.9, 40], [0.73, 0.22, "d", 0.9, 0], [0.64, 0.1, "c", 1.1, -12], ], }, { farbe: "cyan", grad: "7", griffe: [ [0.83, 0.85, "b", 1, -5], [0.9, 0.72, "a", 0.9, 30], [0.79, 0.6, "e", 1, -15], [0.91, 0.48, "c", 0.95, 10], [0.8, 0.36, "a", 1, -35], [0.9, 0.24, "b", 0.9, 15], [0.84, 0.11, "d", 1.1, 0], ], }, ]; /** Kleine graue Tritte zwischen den Routen: [x, y, Form, Größe, Drehung]. */ const TRITTE: readonly Griff[] = [ [0.06, 0.66, "d", 0.4, 0], [0.31, 0.8, "c", 0.45, 20], [0.52, 0.64, "d", 0.4, 0], [0.47, 0.88, "d", 0.42, 0], [0.77, 0.77, "c", 0.45, -15], [0.96, 0.62, "d", 0.4, 0], [0.31, 0.5, "d", 0.38, 0], [0.55, 0.4, "c", 0.45, 30], [0.74, 0.4, "d", 0.38, 0], [0.07, 0.4, "c", 0.45, -20], [0.34, 0.2, "d", 0.38, 0], [0.56, 0.2, "d", 0.4, 0], [0.96, 0.34, "c", 0.45, 25], [0.06, 0.2, "d", 0.38, 0], ]; const Griffe = ({ griff, farbe, mass }: { griff: Griff; farbe: Farbe | "grau"; mass: number }) => { const [x, y, form, groesse, drehung] = griff; const f = farbe === "grau" ? { hell: "#b9b6ae", dunkel: "#7d7a73" } : FARBEN[farbe]; const pfad = FORMEN[form]; const g = groesse * mass; return ( ); }; /** Die Wand als SVG. b und h sind die Maße der Zeichnung, `klein` ist die Handy-Fassung (weniger, kleinere Griffe). */ const Routenwand = ({ b, h, klein }: { b: number; h: number; klein?: boolean }) => { const id = useId().replace(/[^a-zA-Z0-9]/g, ""); // flache Wand (Desktop, 260 hoch): kleinere Griffe, sonst drängen sie sich const mass = klein ? 0.5 : h < 300 ? 0.68 : 1; const px = (x: number) => x * b; const py = (y: number) => h * (0.17 + (y - 0.1) * 0.9); const matte = h * 0.93; return ( ); }; // ── Kleine Zeichnungen ─────────────────────────────────────────────────────── const LogoZeichen = () => ( ); const Pfeil = () => ( ); const Handset = () => ( ); // ── Messen und Klickpunkte ──────────────────────────────────────────────────── type Rechtecke = Record; const gleich = (a: Rechtecke, b: Rechtecke) => Object.keys(b).length === Object.keys(a).length && Object.entries(b).every(([id, r]) => { const alt = a[id]; return ( alt && Math.abs(alt.x - r.x) < 0.5 && Math.abs(alt.y - r.y) < 0.5 && Math.abs(alt.b - r.b) < 0.5 && Math.abs(alt.h - r.h) < 0.5 ); }); /** * Misst die Zonen-Rechtecke relativ zur Fläche in Designpunkten. getBoundingClientRect liefert Bildschirmpunkte; * der Maßstab des Rahmens (transform: scale) ergibt sich aus Bildschirmbreite / Layoutbreite der Fläche. * Ohne Messung (jsdom, ausgeblendet) gelten die festen Ersatz-Rechtecke. */ const useGemessen = (ref: RefObject, variante: DemoVariante) => { const [rechtecke, setRechtecke] = useState(ZONEN_LAYOUT[variante]); useLayoutEffect(() => { const el = ref.current; if (!el) return; let aktiv = true; const messe = () => { if (!aktiv) return; const fl = el.getBoundingClientRect(); if (!fl.width || !el.offsetWidth) return; const k = fl.width / el.offsetWidth; const neu: Rechtecke = {}; el.querySelectorAll("[data-zone]").forEach((z) => { const r = z.getBoundingClientRect(); neu[z.dataset.zone as string] = { x: (r.left - fl.left) / k, y: (r.top - fl.top) / k, b: r.width / k, h: r.height / k, }; }); if (Object.keys(neu).length === 0) return; setRechtecke((alt) => (gleich(alt, neu) ? alt : neu)); }; messe(); const beobachter = typeof ResizeObserver === "function" ? new ResizeObserver(messe) : null; beobachter?.observe(el); // Die Schrift lädt später und verschiebt Zeilenumbrüche: dann neu messen document.fonts?.ready.then(messe).catch(() => {}); window.addEventListener("resize", messe); return () => { aktiv = false; beobachter?.disconnect(); window.removeEventListener("resize", messe); }; }, [ref, variante]); return rechtecke; }; /** * Wie viele Punkte eine Zone bekommt. Die Skala der Heatmap sättigt schon bei zwei, drei überlagerten Flecken (rot); * die Dichte steigt darum steil mit dem Klickanteil (Tageskarte = 1, nur sie wird durchgehend rot). * Jede Zone bekommt aber mindestens so viele Punkte, dass ihre Klicks sichtbar sind (Wunsch des Inhabers 2026-10-04: * nicht nur Punkte auf „Tageskarte kaufen“, auch z. B. die Telefonnummer). Große Zonen bekommen mehr Punkte. */ const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => { const anteil = zone.klicks / TOP_ZONE.klicks; // Fläche eines Flecks in Designpunkten (Leinwand = halbe Fläche, der Radius gilt in Leinwandpunkten) const fleck = Math.PI * (2 * LEINWAND[variante].radius) ** 2; const flecke = Math.max(r.b * r.h, 2 * fleck) / (2 * fleck); const obergrenze = Math.max(1, Math.round(zone.klicks / KLICKS_JE_PUNKT[variante])); const minimum = Math.round(zone.klicks / MINDEST_KLICKS_JE_PUNKT[variante]); return Math.min(obergrenze, Math.max(minimum, Math.round(anteil ** 1.8 * 8 * flecke))); }; /** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */ const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record => { const { breite, hoehe } = FLAECHE_GROESSE[variante]; const aus: Record = {}; ZONEN.forEach((zone, i) => { const r = rechtecke[zone.id] ?? ZONEN_LAYOUT[variante][zone.id]; const rand = Math.min(r.b, r.h) * 0.3; const zufallsfolge = zufall(1000 + i * 17 + (variante === "handy" ? 5 : 0)); const anzahl = punktAnzahl(zone, r, variante); const kern = Math.round(anzahl * 0.85); const innen = punkteInZone({ x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe }, kern, zufallsfolge); const streu = punkteInZone( { x: (r.x - rand) / breite, y: (r.y - rand) / hoehe, b: (r.b + 2 * rand) / breite, h: (r.h + 2 * rand) / hoehe, }, anzahl - kern, zufallsfolge, ); // mischen, damit jede Teilmenge (die ersten n Punkte) gleichmäßig über die Zone verteilt ist const alle = [...innen, ...streu].map((p) => ({ x: Math.min(1, Math.max(0, p.x)), y: Math.min(1, Math.max(0, p.y)), })); for (let n = alle.length - 1; n > 0; n--) { const m = Math.floor(zufallsfolge() * (n + 1)); [alle[n], alle[m]] = [alle[m], alle[n]]; } aus[zone.id] = alle; }); return aus; }; const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const; /** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 5 Punkte auf dem MacBook). */ const MINDEST_KLICKS_JE_PUNKT = { desktop: 18, handy: 30 } as const; const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const; // ── Seite ─────────────────────────────────────────────────────────────────── const HeatmapSeite = ({ zustand = HEATMAP_START, variante }: Props) => { const wurzel = useRef(null); const leinwand = useRef(null); const fein = useFeinerZeiger(); const rechtecke = useGemessen(wurzel, variante); const punkte = useMemo(() => erzeugePunkte(rechtecke, variante), [rechtecke, variante]); const { fuellung, statisch } = zustand; const bewegung = useBewegungErlaubt(); const ebene = useRef | null>(null); /** Stärke jedes Demo-Klicks, wie sie gerade gezeichnet ist (0 bis 1) */ const staerken = useRef([]); // Grundbestand (Klicks der letzten 30 Tage): neu zeichnen nur bei neuer Messung, nicht je Bild useEffect(() => { const c = leinwand.current; if (!c) return; ebene.current = heatEbene(c, LEINWAND[variante].radius); const grund: HeatPunkt[] = []; for (const z of ZONEN) { const alle = punkte[z.id]; grund.push(...alle.slice(0, Math.round((fuellung[z.id] ?? 0) * alle.length))); } ebene.current.basis(grund); staerken.current = []; }, [punkte, fuellung, variante]); // Demo-Klicks: jeder Klick ist genau EIN Punkt an der Stelle des Zeigers. Ein neuer Punkt wächst in gut 0,4 s weich // herein, beim Neustart blenden alle Demo-Klicks ebenso weich aus. Neu eingefärbt wird je Bild nur der kleine // Ausschnitt um die Punkte, die sich ändern; nach der Überblendung läuft nichts mehr. const gesetzt = zustand.klickPunkte[variante].length; useEffect(() => { const e = ebene.current; if (!e) return; const { breite, hoehe } = FLAECHE_GROESSE[variante]; const lage = KLICK_ZIELE[variante].map((p) => ({ x: p.x / breite, y: p.y / hoehe })); const von = lage.map((_, i) => staerken.current[i] ?? 0); const nach = lage.map((_, i) => (i < gesetzt ? 1 : 0)); const setze = (t: number) => { staerken.current = von.map((v, i) => v + (nach[i] - v) * t); e.klicks(lage.map((p, i): KlickPunkt => ({ ...p, staerke: staerken.current[i] }))); }; if (!bewegung || von.every((v, i) => v === nach[i])) { setze(1); return; } const dauer = nach.some((n, i) => n < von[i]) ? 700 : 420; let raf = 0; const beginn = performance.now(); const bild = (jetzt: number) => { const t = Math.min(1, (jetzt - beginn) / dauer); setze(1 - (1 - t) ** 3); // sanft abbremsend if (t < 1) raf = requestAnimationFrame(bild); }; raf = requestAnimationFrame(bild); return () => cancelAnimationFrame(raf); }, [gesetzt, variante, bewegung, punkte, fuellung]); const zoneUnter = (e: MouseEvent) => { const z = (e.target as Element).closest?.("[data-zone]"); return z?.getAttribute("data-zone") ?? null; }; const bewegt = (e: MouseEvent) => { if (!zustand.maus) zustand.setzeMaus(true); const id = zoneUnter(e); if (id !== zustand.auswahl) zustand.waehle(id); }; const verlassen = () => { zustand.setzeMaus(false); zustand.waehle(null); }; const getippt = (e: MouseEvent) => { const id = zoneUnter(e); // Maus: wählen. Finger: wählen, nochmal tippen hebt die Auswahl auf. zustand.waehle(!fein && id !== null && id === zustand.auswahl ? null : id); }; const gewaehlt = zoneNach(zustand.auswahl); const r = gewaehlt ? rechtecke[gewaehlt.id] : undefined; const { breite } = FLAECHE_GROESSE[variante]; const zeiger = zustand.zeiger[variante]; // Schildchen über der Zone; im Kopf der Seite und auf dem Handy in der oberen Hälfte darunter (sonst verdeckt es die Überschrift) // Große Zonen (das Routenbild) tragen das Schildchen im Inneren, sonst würde es Nachbarn verdecken. const innen = r ? r.h >= (variante === "desktop" ? 250 : 140) : false; const unten = !innen && r ? (variante === "desktop" ? r.y < 80 : r.y < 440) : false; const lage = innen ? "innen" : unten ? "unter" : "ueber"; return (