diff --git a/src/components/home/demos/heat.ts b/src/components/home/demos/heat.ts index a839608..c75b7fd 100644 --- a/src/components/home/demos/heat.ts +++ b/src/components/home/demos/heat.ts @@ -76,3 +76,105 @@ export const punkteInZone = (zone: ZonenRechteck, anzahl: number, zufall: () => } return punkte; }; + +// ── Heatmap mit einzelnen, weich erscheinenden Klickpunkten ─────────────────────────── + +/** Ein Klick der Demo: Lage relativ zur Fläche (0 bis 1) und Stärke 0 (unsichtbar) bis 1 (voll). */ +export type KlickPunkt = HeatPunkt & { staerke: number }; + +/** Deckkraft eines Flecks wie im echten Overlay (h.js). Ein frischer Demo-Klick zählt etwas stärker, damit man ihn sieht. */ +const FLECK_ALPHA = 0.25; +const KLICK_GEWICHT = 1.6; + +const fleck = (g: CanvasRenderingContext2D, x: number, y: number, radius: number, alpha: number) => { + const rg = g.createRadialGradient(x, y, 0, x, y, radius); + rg.addColorStop(0, `rgba(0,0,0,${alpha})`); + rg.addColorStop(1, "rgba(0,0,0,0)"); + g.fillStyle = rg; + g.fillRect(x - radius, y - radius, radius * 2, radius * 2); +}; + +/** Färbt die Deckkraft-Stufen eines Bildausschnitts mit der Farbskala ein (wie h.js). */ +const faerbe = (d: Uint8ClampedArray, farben: Uint8ClampedArray) => { + for (let i = 3; i < d.length; i += 4) { + const a = d[i]; + if (!a) continue; + const k = Math.min(255, a * 3) * 4; + d[i - 3] = farben[k]; + d[i - 2] = farben[k + 1]; + d[i - 1] = farben[k + 2]; + d[i] = Math.min(200, 60 + a); + } +}; + +const neueLeinwand = (b: number, h: number) => { + const c = document.createElement("canvas"); + c.width = Math.max(1, b); + c.height = Math.max(1, h); + return c; +}; + +/** + * Heatmap-Ebene der Demo: der Grundbestand (Klicks der letzten 30 Tage) wird einmal als Deckkraft-Karte gezeichnet; + * einzelne Demo-Klicks lassen sich danach mit beliebiger Stärke dazulegen. Ändert sich nur ein Klick, wird nur sein + * kleiner Ausschnitt neu berechnet (ein paar Tausend Pixel) – so kann ein neuer Punkt über mehrere Bilder weich + * erscheinen, ohne die ganze Fläche je Bild neu zu zeichnen. Ohne 2D-Kontext (jsdom) passiert nichts. + */ +export const heatEbene = (sichtbar: HTMLCanvasElement, radius: number) => { + const b = sichtbar.width; + const h = sichtbar.height; + const basis = neueLeinwand(b, h); + const gBasis = basis.getContext("2d", { willReadFrequently: true }); + const gSicht = sichtbar.getContext("2d", { willReadFrequently: true }); + let klicks: readonly KlickPunkt[] = []; + + /** Deckkraft-Karte eines Ausschnitts: Grundbestand plus alle Klicks, die hineinreichen. */ + const karte = (x0: number, y0: number, w: number, hh: number) => { + const teil = neueLeinwand(w, hh); + const g = teil.getContext("2d", { willReadFrequently: true }); + if (!g || !gBasis) return null; + g.putImageData(gBasis.getImageData(x0, y0, w, hh), 0, 0); + for (const k of klicks) { + if (k.staerke <= 0) continue; + const x = k.x * b - x0; + const y = k.y * h - y0; + if (x < -radius || y < -radius || x > w + radius || y > hh + radius) continue; + fleck(g, x, y, radius, Math.min(1, FLECK_ALPHA * KLICK_GEWICHT * k.staerke)); + } + return g.getImageData(0, 0, w, hh); + }; + + const zeige = (x0: number, y0: number, w: number, hh: number) => { + const farben = farbskala(); + if (!gSicht || !farben || w <= 0 || hh <= 0) return; + const bild = karte(x0, y0, w, hh); + if (!bild) return; + faerbe(bild.data, farben); + gSicht.putImageData(bild, x0, y0); + }; + + return { + /** Grundbestand neu zeichnen (z. B. nach neuer Messung der Zonen) und alles neu einfärben. */ + basis(punkte: readonly HeatPunkt[]) { + if (!gBasis) return; + gBasis.clearRect(0, 0, b, h); + for (const p of punkte) fleck(gBasis, p.x * b, p.y * h, radius, FLECK_ALPHA); + zeige(0, 0, b, h); + }, + /** Klicks setzen; neu eingefärbt werden nur die Ausschnitte der Klicks, deren Stärke sich geändert hat. */ + klicks(neu: readonly KlickPunkt[]) { + const alt = klicks; + klicks = neu; + const laenge = Math.max(alt.length, neu.length); + for (let i = 0; i < laenge; i++) { + const k = neu[i] ?? alt[i]; + if (alt[i] && neu[i] && alt[i].staerke === neu[i].staerke && alt[i].x === neu[i].x && alt[i].y === neu[i].y) continue; + const x0 = Math.max(0, Math.floor(k.x * b - radius - 1)); + const y0 = Math.max(0, Math.floor(k.y * h - radius - 1)); + const x1 = Math.min(b, Math.ceil(k.x * b + radius + 1)); + const y1 = Math.min(h, Math.ceil(k.y * h + radius + 1)); + zeige(x0, y0, x1 - x0, y1 - y0); + } + }, + }; +}; diff --git a/src/components/home/demos/heatmap/HeatmapSeite.tsx b/src/components/home/demos/heatmap/HeatmapSeite.tsx index 6dc8512..91c1ba7 100644 --- a/src/components/home/demos/heatmap/HeatmapSeite.tsx +++ b/src/components/home/demos/heatmap/HeatmapSeite.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react"; -import { DemoZeiger, useFeinerZeiger, zufall, type DemoVariante } from "../gemeinsam"; -import { punkteInZone, zeichneHeatmap, type HeatPunkt } from "../heat"; -import { HEATMAP_START, type HeatmapZustand } from "./ablauf"; +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"; @@ -382,20 +382,56 @@ const HeatmapSeite = ({ zustand = HEATMAP_START, variante }: Props) => { 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([]); - // Neu zeichnen nur, wenn sich die sichtbaren Punkte ändern (neuer Klick, neue Messung), nicht je Bild + // Grundbestand (Klicks der letzten 30 Tage): neu zeichnen nur bei neuer Messung, nicht je Bild useEffect(() => { const c = leinwand.current; if (!c) return; - const sichtbar: HeatPunkt[] = []; + ebene.current = heatEbene(c, LEINWAND[variante].radius); + const grund: HeatPunkt[] = []; for (const z of ZONEN) { const alle = punkte[z.id]; - const anzahl = Math.round((fuellung[z.id] ?? 0) * alle.length); - if (anzahl > 0) sichtbar.push(...alle.slice(0, anzahl)); + grund.push(...alle.slice(0, Math.round((fuellung[z.id] ?? 0) * alle.length))); } - zeichneHeatmap(c, sichtbar, LEINWAND[variante].radius); + 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; diff --git a/src/components/home/demos/heatmap/ablauf.ts b/src/components/home/demos/heatmap/ablauf.ts index 760b9b4..1be9a80 100644 --- a/src/components/home/demos/heatmap/ablauf.ts +++ b/src/components/home/demos/heatmap/ablauf.ts @@ -1,7 +1,7 @@ /** - * Ablauf der Demo „Website-Analyse“: Die Heatmap ist von Anfang an gut zu sehen (Grundbestand der letzten 30 Tage), - * ein simulierter Zeiger klickt nacheinander Stellen der Beispiel-Website an, und jeder Klick fügt an seiner Zone nur - * ein paar Punkte hinzu; nach ca. 10 s ist sie voll, bleibt ca. 6 s stehen und blendet aus. + * Ablauf der Demo „Website-Analyse“: Die Heatmap der letzten 30 Tage ist von Anfang an ganz zu sehen. Ein simulierter + * Zeiger klickt nacheinander Stellen der Beispiel-Website an; jeder Klick legt genau EINEN neuen Punkt an die Stelle des + * Zeigers (Wunsch des Inhabers 2026-10-04). Nach ca. 10 s hält der Ablauf ca. 6 s, die Demo-Klicks blenden aus, neu. * Sobald die Maus über der Website ist (oder eine Zone gewählt wird), stoppt der Ablauf und die Heatmap ist voll. * Diese Datei wird sofort geladen und darf kein CSS importieren. Es laufen nur setTimeout-Ketten, kein rAF, * und nichts, solange `laeuft` false ist. @@ -16,8 +16,10 @@ export type HeatmapZeiger = { x: number; y: number; klick: number; sichtbar: boo export type HeatmapZustand = { phase: HeatmapPhase; - /** Wie viele Klickpunkte je Zone sichtbar sind: 0 (keine) bis 1 (alle). */ + /** Wie viele Klickpunkte je Zone sichtbar sind: 0 (keine) bis 1 (alle); der Grundbestand ist immer ganz zu sehen. */ fuellung: Readonly>; + /** Die Demo-Klicks bis jetzt, je Fassung in Designpunkten der Fläche (genau die Stellen, an denen der Zeiger klickte). */ + klickPunkte: Record; /** Anzahl sichtbarer Klickpunkte insgesamt (von 1.284) und ihr Anteil 0 bis 1. */ punkte: number; anteil: number; @@ -95,36 +97,14 @@ export const AUFBAU_DAUER = SCHRITTE.slice(0, VOLL_START).reduce((summe, s) => s // ── Füllung der Heatmap nach jedem Klick ─────────────────────────────────── -/** Grundbestand: Anteil der Punkte jeder Zone, der schon vor dem ersten Klick zu sehen ist (Wunsch des Inhabers - * 2026-10-04: die Heatmap soll gut sichtbar sein, ein Klick soll nicht viele Punkte auf einmal bringen). */ -const BASIS_ANTEIL = 0.7; -const BASIS: Readonly> = Object.freeze( - Object.fromEntries(ZONEN.map((z) => [z.id, BASIS_ANTEIL])), -); +/** Der Grundbestand: alle Punkte jeder Zone sind von Anfang an zu sehen. */ const ALLE: Readonly> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 1]))); -/** - * Füllstand aller Zonen nach dem n-ten Klick: Jeder Besuch einer Zone gibt nur einen kleinen Teil ihres Rests frei - * (vom Grundbestand bis voll, aufgeteilt auf ihre Besuche), andere Zonen bleiben unverändert. Je Klick erscheinen - * so nur wenige neue Punkte, genau an der angeklickten Stelle. Nach dem letzten Klick ist alles voll. - */ -const VERLAUF: readonly Readonly>[] = (() => { - const besuche: Record = {}; - for (const id of KLICK_FOLGE) besuche[id] = (besuche[id] ?? 0) + 1; - const gezaehlt: Record = {}; - const stand: Record = { ...BASIS }; - return KLICK_FOLGE.map((id, i) => { - gezaehlt[id] = (gezaehlt[id] ?? 0) + 1; - stand[id] = BASIS_ANTEIL + (1 - BASIS_ANTEIL) * (gezaehlt[id] / besuche[id]); - if (i === KLICK_FOLGE.length - 1) return ALLE; - return Object.freeze( - Object.fromEntries(Object.entries(stand).map(([k, v]) => [k, Math.round(v * 100) / 100])), - ) as Readonly>; - }); -})(); +/** Wie viele Demo-Klicks es insgesamt gibt; der Grundbestand sind die übrigen Klicks der Summe. */ +const DEMO_KLICKS = KLICK_FOLGE.length; -const zaehlePunkte = (fuellung: Readonly>) => - ZONEN.reduce((summe, z) => summe + Math.round((fuellung[z.id] ?? 0) * z.klicks), 0); +/** Gezählte Klicks: Grundbestand plus die Demo-Klicks bis jetzt (am Ende genau die Summe 1.284). */ +const zaehlePunkte = (klicks: number) => SUMME_KLICKS - DEMO_KLICKS + klicks; // ── Zeigerziele ────────────────────────────────────────────────────────────── @@ -138,6 +118,9 @@ const ziele = (variante: DemoVariante) => const ZIELE: Record = { desktop: ziele("desktop"), handy: ziele("handy") }; +/** Die Stellen aller Demo-Klicks je Fassung (Designpunkte), in Klick-Reihenfolge – auch zum Ausblenden gebraucht. */ +export const KLICK_ZIELE: Readonly> = ZIELE; + // ── Ableitung aus dem Schritt ────────────────────────────────────────────────── const aus = (schritt: number) => SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)]; @@ -158,14 +141,20 @@ const zeigerFuer = (variante: DemoVariante, schritt: number, aktiv: boolean): He return { ...letzte, klick: KLICK_FOLGE.length, sichtbar: false }; }; -const fuellungFuer = (schritt: number): Readonly> => { +/** Wie viele Demo-Klicks zu diesem Schritt schon gesetzt sind (beim Ausblenden wieder keiner). */ +const klicksFuer = (schritt: number) => { const s = aus(schritt); - if (s.art === "start") return BASIS; - if (s.art === "flug") return s.ziel === 0 ? BASIS : VERLAUF[s.ziel - 1]; - if (s.art === "klick") return VERLAUF[s.ziel]; - return ALLE; + if (s.art === "flug") return s.ziel; + if (s.art === "klick") return s.ziel + 1; + if (s.art === "voll") return DEMO_KLICKS; + return 0; }; +const klickPunkteFuer = (klicks: number): HeatmapZustand["klickPunkte"] => ({ + desktop: ZIELE.desktop.slice(0, klicks), + handy: ZIELE.handy.slice(0, klicks), +}); + const phaseFuer = (schritt: number): HeatmapPhase => { const art = aus(schritt).art; return art === "voll" ? "voll" : art === "aus" ? "ausblenden" : "aufbau"; @@ -175,9 +164,10 @@ const phaseFuer = (schritt: number): HeatmapPhase => { * Website samt Heatmap sofort, bevor der Taktgeber geladen ist. */ export const HEATMAP_START: HeatmapZustand = { phase: "aufbau", - fuellung: BASIS, - punkte: zaehlePunkte(BASIS), - anteil: zaehlePunkte(BASIS) / SUMME_KLICKS, + fuellung: ALLE, + klickPunkte: klickPunkteFuer(0), + punkte: zaehlePunkte(0), + anteil: zaehlePunkte(0) / SUMME_KLICKS, zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) }, auswahl: null, maus: false, @@ -229,12 +219,13 @@ export const useHeatmapAblauf = (laeuft: boolean, statisch: boolean): HeatmapZus return useMemo(() => { const voll = statisch || interaktiv; - const fuellung = voll ? ALLE : fuellungFuer(schritt); - const punkte = voll ? SUMME_KLICKS : zaehlePunkte(fuellung); + const klicks = voll ? DEMO_KLICKS : klicksFuer(schritt); + const punkte = zaehlePunkte(klicks); const zeigerAktiv = spielt && phaseFuer(schritt) === "aufbau"; return { phase: voll ? "voll" : phaseFuer(schritt), - fuellung, + fuellung: ALLE, + klickPunkte: klickPunkteFuer(klicks), punkte, anteil: punkte / SUMME_KLICKS, zeiger: { diff --git a/src/components/home/demos/heatmap/heatmap.css b/src/components/home/demos/heatmap/heatmap.css index ea6e0ed..7ff54db 100644 --- a/src/components/home/demos/heatmap/heatmap.css +++ b/src/components/home/demos/heatmap/heatmap.css @@ -531,9 +531,6 @@ pointer-events: none; opacity: 0.92; } -.hm[data-phase="ausblenden"] .hm-heat { - opacity: 0; -} .hm-rahmen { position: absolute; @@ -591,12 +588,6 @@ transform 520ms cubic-bezier(0.45, 0, 0.2, 1), opacity 300ms ease; } - .hm .hm-heat { - transition: opacity 900ms ease; - } - .hm[data-phase="aufbau"] .hm-heat { - transition-duration: 150ms; - } .hm-rahmen, .hm-schild { animation: hm-ein 160ms ease-out both;