Heatmap-Demo: sofort sichtbar, je Klick nur wenige neue Punkte, etwas weniger Streuung

- Die Heatmap beginnt mit 70 % ihrer Punkte (Grundbestand) statt leer; jeder simulierte
  Klick gibt nur einen kleinen Teil des Rests seiner Zone frei (gemessen 1–4 Punkte je
  Klick statt rund 18), keine Streuung mehr in fremde Zonen.
- Dichte der übrigen Zonen etwas zurückgenommen (Exponent 1,8, Mindestens 1 Punkt je
  18 Klicks); „Tageskarte kaufen“ bleibt klar am heißesten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-04 15:48:47 +00:00
1 parent 2f4ef5309b
commit adb1587a7b
3 files changed
+35 -37

No files matched your search

@@ -329,7 +329,7 @@ const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
const flecke = Math.max(r.b * r.h, 2 * fleck) / (2 * fleck); 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 obergrenze = Math.max(1, Math.round(zone.klicks / KLICKS_JE_PUNKT[variante]));
const minimum = Math.round(zone.klicks / MINDEST_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.6 * 8 * flecke))); 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. */ /** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
@@ -368,8 +368,8 @@ const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record<str
}; };
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const; const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
/** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 7 Punkte auf dem MacBook). */ /** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 5 Punkte auf dem MacBook). */
const MINDEST_KLICKS_JE_PUNKT = { desktop: 14, handy: 24 } as const; 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; const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const;
+22 -27
View File
@@ -1,6 +1,7 @@
/** /**
* Ablauf der Demo „Website-Analyse“: ein simulierter Zeiger klickt nacheinander Stellen der Beispiel-Website an, * Ablauf der Demo „Website-Analyse“: Die Heatmap ist von Anfang an gut zu sehen (Grundbestand der letzten 30 Tage),
* die Heatmap wird mit jedem Klick dichter, ist nach ca. 10 s voll, bleibt ca. 6 s stehen und blendet aus. * 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.
* Sobald die Maus über der Website ist (oder eine Zone gewählt wird), stoppt der Ablauf und die Heatmap ist voll. * 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, * Diese Datei wird sofort geladen und darf kein CSS importieren. Es laufen nur setTimeout-Ketten, kein rAF,
* und nichts, solange `laeuft` false ist. * und nichts, solange `laeuft` false ist.
@@ -94,33 +95,27 @@ export const AUFBAU_DAUER = SCHRITTE.slice(0, VOLL_START).reduce((summe, s) => s
// ── Füllung der Heatmap nach jedem Klick ─────────────────────────────────── // ── Füllung der Heatmap nach jedem Klick ───────────────────────────────────
const KEINE: Readonly<Record<string, number>> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 0]))); /** 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])),
);
const ALLE: Readonly<Record<string, number>> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 1]))); 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 bringt einen Schub Punkte (k von n Besuchen); * Füllstand aller Zonen nach dem n-ten Klick: Jeder Besuch einer Zone gibt nur einen kleinen Teil ihres Rests frei
* dazu streut jeder Klick ein paar Punkte in zwei andere Zonen, so wächst die Heatmap nicht Zone für Zone. * (vom Grundbestand bis voll, aufgeteilt auf ihre Besuche), andere Zonen bleiben unverändert. Je Klick erscheinen
* Nach dem letzten Klick ist alles voll. Feste Saat, also bei jedem Durchlauf gleich. * 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 VERLAUF: readonly Readonly<Record<string, number>>[] = (() => {
const besuche: Record<string, number> = {}; const besuche: Record<string, number> = {};
for (const id of KLICK_FOLGE) besuche[id] = (besuche[id] ?? 0) + 1; for (const id of KLICK_FOLGE) besuche[id] = (besuche[id] ?? 0) + 1;
const gezaehlt: Record<string, number> = {}; const gezaehlt: Record<string, number> = {};
const stand: Record<string, number> = Object.fromEntries(ZONEN.map((z) => [z.id, 0])); const stand: Record<string, number> = { ...BASIS };
const ids = ZONEN.map((z) => z.id);
let a = 7;
const naechste = () => {
a = (a * 1103515245 + 12345) % 2147483648;
return a / 2147483648;
};
return KLICK_FOLGE.map((id, i) => { return KLICK_FOLGE.map((id, i) => {
gezaehlt[id] = (gezaehlt[id] ?? 0) + 1; gezaehlt[id] = (gezaehlt[id] ?? 0) + 1;
stand[id] = Math.max(stand[id], gezaehlt[id] / besuche[id]); stand[id] = BASIS_ANTEIL + (1 - BASIS_ANTEIL) * (gezaehlt[id] / besuche[id]);
for (let s = 0; s < 2; s++) {
const fremd = ids[Math.floor(naechste() * ids.length)];
// Streuung nur in Zonen, die noch nicht fertig sind, und höchstens bis knapp über die Hälfte
if (fremd !== id && gezaehlt[fremd] !== besuche[fremd]) stand[fremd] = Math.min(0.55, stand[fremd] + 0.07);
}
if (i === KLICK_FOLGE.length - 1) return ALLE; if (i === KLICK_FOLGE.length - 1) return ALLE;
return Object.freeze( return Object.freeze(
Object.fromEntries(Object.entries(stand).map(([k, v]) => [k, Math.round(v * 100) / 100])), Object.fromEntries(Object.entries(stand).map(([k, v]) => [k, Math.round(v * 100) / 100])),
@@ -165,8 +160,8 @@ const zeigerFuer = (variante: DemoVariante, schritt: number, aktiv: boolean): He
const fuellungFuer = (schritt: number): Readonly<Record<string, number>> => { const fuellungFuer = (schritt: number): Readonly<Record<string, number>> => {
const s = aus(schritt); const s = aus(schritt);
if (s.art === "start") return KEINE; if (s.art === "start") return BASIS;
if (s.art === "flug") return s.ziel === 0 ? KEINE : VERLAUF[s.ziel - 1]; if (s.art === "flug") return s.ziel === 0 ? BASIS : VERLAUF[s.ziel - 1];
if (s.art === "klick") return VERLAUF[s.ziel]; if (s.art === "klick") return VERLAUF[s.ziel];
return ALLE; return ALLE;
}; };
@@ -176,13 +171,13 @@ const phaseFuer = (schritt: number): HeatmapPhase => {
return art === "voll" ? "voll" : art === "aus" ? "ausblenden" : "aufbau"; return art === "voll" ? "voll" : art === "aus" ? "ausblenden" : "aufbau";
}; };
/** Anfangszustand (leere Heatmap, nichts gewählt, Auswahl und Maus ohne Wirkung): damit zeigt die Bühne die Website sofort, /** Anfangszustand (Grundbestand der Heatmap, nichts gewählt, Auswahl und Maus ohne Wirkung): damit zeigt die Bühne die
* bevor der Taktgeber geladen ist. */ * Website samt Heatmap sofort, bevor der Taktgeber geladen ist. */
export const HEATMAP_START: HeatmapZustand = { export const HEATMAP_START: HeatmapZustand = {
phase: "aufbau", phase: "aufbau",
fuellung: KEINE, fuellung: BASIS,
punkte: 0, punkte: zaehlePunkte(BASIS),
anteil: 0, anteil: zaehlePunkte(BASIS) / SUMME_KLICKS,
zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) }, zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) },
auswahl: null, auswahl: null,
maus: false, maus: false,
@@ -198,7 +193,7 @@ export const HEATMAP_START: HeatmapZustand = {
* Zustand der Heatmap-Demo. * Zustand der Heatmap-Demo.
* laeuft = der Ablauf darf abspielen (Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt). * laeuft = der Ablauf darf abspielen (Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt).
* statisch = keine Bewegung erlaubt: volle Heatmap, kein Zeiger, Auswahl = meistgeklickte Zone. * statisch = keine Bewegung erlaubt: volle Heatmap, kein Zeiger, Auswahl = meistgeklickte Zone.
* Hört `laeuft` auf, beginnt der Ablauf beim nächsten Mal von vorn (leere Heatmap). * Hört `laeuft` auf, beginnt der Ablauf beim nächsten Mal von vorn (beim Grundbestand).
*/ */
export const useHeatmapAblauf = (laeuft: boolean, statisch: boolean): HeatmapZustand => { export const useHeatmapAblauf = (laeuft: boolean, statisch: boolean): HeatmapZustand => {
const [schritt, setSchritt] = useState(0); const [schritt, setSchritt] = useState(0);
+10 -7
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within, act, cleanup, fireEvent } from "@testing-library/react"; import { render, screen, within, act, cleanup, fireEvent } from "@testing-library/react";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { useHeatmapAblauf, AUFBAU_DAUER } from "@/components/home/demos/heatmap/ablauf"; import { useHeatmapAblauf, AUFBAU_DAUER, HEATMAP_START } from "@/components/home/demos/heatmap/ablauf";
import HeatmapSeite from "@/components/home/demos/heatmap/HeatmapSeite"; import HeatmapSeite from "@/components/home/demos/heatmap/HeatmapSeite";
import HeatmapDiagnose from "@/components/home/demos/heatmap/HeatmapDiagnose"; import HeatmapDiagnose from "@/components/home/demos/heatmap/HeatmapDiagnose";
import { import {
@@ -221,7 +221,7 @@ describe("Website ist reine Dekoration", () => {
}); });
describe("Ablauf", () => { describe("Ablauf", () => {
it("läuft nichts, solange laeuft=false ist: keine Timer, leere Heatmap, kein sichtbarer Zeiger", () => { it("läuft nichts, solange laeuft=false ist: keine Timer, Heatmap im Grundbestand, kein sichtbarer Zeiger", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { container } = render(<Probe laeuft={false} statisch={false} />); const { container } = render(<Probe laeuft={false} statisch={false} />);
expect(vi.getTimerCount()).toBe(0); expect(vi.getTimerCount()).toBe(0);
@@ -229,7 +229,10 @@ describe("Ablauf", () => {
const wurzel = container.querySelector(".hm-desktop") as HTMLElement; const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
expect(wurzel).toHaveAttribute("data-phase", "aufbau"); expect(wurzel).toHaveAttribute("data-phase", "aufbau");
expect(wurzel).toHaveAttribute("data-laeuft", "nein"); expect(wurzel).toHaveAttribute("data-laeuft", "nein");
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "0"); // die Heatmap ist von Anfang an gut zu sehen (Grundbestand), aber noch nicht voll
expect(HEATMAP_START.punkte).toBeGreaterThan(0.6 * 1284);
expect(HEATMAP_START.punkte).toBeLessThan(1284);
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", String(HEATMAP_START.punkte));
expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein"); expect(wurzel.querySelector(".dz-zeiger")).toHaveAttribute("data-sichtbar", "nein");
}); });
@@ -245,7 +248,7 @@ describe("Ablauf", () => {
vorspulen(3_000); vorspulen(3_000);
const frueh = heat(); const frueh = heat();
expect(frueh).toBeGreaterThan(0); expect(frueh).toBeGreaterThan(HEATMAP_START.punkte);
expect(frueh).toBeLessThan(1284); expect(frueh).toBeLessThan(1284);
expect(wurzel).toHaveAttribute("data-phase", "aufbau"); expect(wurzel).toHaveAttribute("data-phase", "aufbau");
@@ -261,7 +264,7 @@ describe("Ablauf", () => {
expect(halten).toBeLessThanOrEqual(6_300); expect(halten).toBeLessThanOrEqual(6_300);
spuleBis(wurzel, "aufbau"); spuleBis(wurzel, "aufbau");
expect(wurzel).toHaveAttribute("data-phase", "aufbau"); expect(wurzel).toHaveAttribute("data-phase", "aufbau");
expect(heat()).toBe(0); expect(heat()).toBe(HEATMAP_START.punkte);
}); });
it("endet laeuft mitten im Aufbau, stehen alle Timer still und der Ablauf beginnt später von vorn", () => { it("endet laeuft mitten im Aufbau, stehen alle Timer still und der Ablauf beginnt später von vorn", () => {
@@ -269,10 +272,10 @@ describe("Ablauf", () => {
const { container, rerender } = render(<Probe laeuft statisch={false} />); const { container, rerender } = render(<Probe laeuft statisch={false} />);
vorspulen(4_000); vorspulen(4_000);
const wurzel = container.querySelector(".hm-desktop") as HTMLElement; const wurzel = container.querySelector(".hm-desktop") as HTMLElement;
expect(Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"))).toBeGreaterThan(0); expect(Number(wurzel.querySelector(".hm-heat")?.getAttribute("data-anteil"))).toBeGreaterThan(HEATMAP_START.punkte);
rerender(<Probe laeuft={false} statisch={false} />); rerender(<Probe laeuft={false} statisch={false} />);
expect(vi.getTimerCount()).toBe(0); expect(vi.getTimerCount()).toBe(0);
expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", "0"); expect(wurzel.querySelector(".hm-heat")).toHaveAttribute("data-anteil", String(HEATMAP_START.punkte));
}); });
it("Maus über der Website: Zeiger aus, Heatmap voll, Ablauf pausiert; danach geht es von dort aus weiter", () => { it("Maus über der Website: Zeiger aus, Heatmap voll, Ablauf pausiert; danach geht es von dort aus weiter", () => {