Heatmap-Demo: ein Klick = ein Punkt an der Zeigerstelle, weich eingeblendet

Wunsch des Inhabers: ein simulierter Klick soll nicht mehrere Heatpunkte auf einmal
bringen. Die Heatmap der letzten 30 Tage ist jetzt von Anfang an ganz zu sehen; jeder
Demo-Klick legt genau einen Punkt an die Stelle des Zeigers, der in gut 0,4 s weich
hereinwächst. Beim Neustart blenden nur die Demo-Klicks aus, nicht die ganze Heatmap.

heat.ts: heatEbene() zeichnet den Grundbestand einmal und färbt je Bild nur den kleinen
Ausschnitt um die sich ändernden Punkte neu ein (gemessen ca. 2.000 Pixel je Klick),
nach der Überblendung läuft nichts mehr. Ohne Bewegung: alles sofort im Endzustand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-04 15:58:50 +00:00
1 parent adb1587a7b
commit 58ac7745e6
4 files changed
+179 -59

No files matched your search

+102
View File
@@ -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);
}
},
};
};
@@ -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<ReturnType<typeof heatEbene> | null>(null);
/** Stärke jedes Demo-Klicks, wie sie gerade gezeichnet ist (0 bis 1) */
const staerken = useRef<number[]>([]);
// 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;
+33 -42
View File
@@ -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<Record<string, number>>;
/** Die Demo-Klicks bis jetzt, je Fassung in Designpunkten der Fläche (genau die Stellen, an denen der Zeiger klickte). */
klickPunkte: Record<DemoVariante, readonly { x: number; y: number }[]>;
/** 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<Record<string, number>> = 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<Record<string, number>> = 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<Record<string, number>>[] = (() => {
const besuche: Record<string, number> = {};
for (const id of KLICK_FOLGE) besuche[id] = (besuche[id] ?? 0) + 1;
const gezaehlt: Record<string, number> = {};
const stand: Record<string, number> = { ...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<Record<string, number>>;
});
})();
/** 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<Record<string, number>>) =>
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<DemoVariante, { x: number; y: number }[]> = { 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<Record<DemoVariante, readonly { x: number; y: number }[]>> = 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<Record<string, number>> => {
/** 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<HeatmapZustand>(() => {
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: {
@@ -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;