From adb1587a7b71019b6159fec483b413adaee2f4cc Mon Sep 17 00:00:00 2001 From: root Date: Sun, 4 Oct 2026 15:48:47 +0000 Subject: [PATCH] Heatmap-Demo: sofort sichtbar, je Klick nur wenige neue Punkte, etwas weniger Streuung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../home/demos/heatmap/HeatmapSeite.tsx | 6 +-- src/components/home/demos/heatmap/ablauf.ts | 49 +++++++++---------- src/test/demo-heatmap.test.tsx | 17 ++++--- 3 files changed, 35 insertions(+), 37 deletions(-) diff --git a/src/components/home/demos/heatmap/HeatmapSeite.tsx b/src/components/home/demos/heatmap/HeatmapSeite.tsx index eae6b27..6dc8512 100644 --- a/src/components/home/demos/heatmap/HeatmapSeite.tsx +++ b/src/components/home/demos/heatmap/HeatmapSeite.tsx @@ -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 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.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. */ @@ -368,8 +368,8 @@ const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record s // ── Füllung der Heatmap nach jedem Klick ─────────────────────────────────── -const KEINE: Readonly> = 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> = Object.freeze( + Object.fromEntries(ZONEN.map((z) => [z.id, BASIS_ANTEIL])), +); 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 bringt einen Schub Punkte (k von n Besuchen); - * dazu streut jeder Klick ein paar Punkte in zwei andere Zonen, so wächst die Heatmap nicht Zone für Zone. - * Nach dem letzten Klick ist alles voll. Feste Saat, also bei jedem Durchlauf gleich. + * 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 = Object.fromEntries(ZONEN.map((z) => [z.id, 0])); - const ids = ZONEN.map((z) => z.id); - let a = 7; - const naechste = () => { - a = (a * 1103515245 + 12345) % 2147483648; - return a / 2147483648; - }; + const stand: Record = { ...BASIS }; return KLICK_FOLGE.map((id, i) => { gezaehlt[id] = (gezaehlt[id] ?? 0) + 1; - stand[id] = Math.max(stand[id], 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); - } + 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])), @@ -165,8 +160,8 @@ const zeigerFuer = (variante: DemoVariante, schritt: number, aktiv: boolean): He const fuellungFuer = (schritt: number): Readonly> => { const s = aus(schritt); - if (s.art === "start") return KEINE; - if (s.art === "flug") return s.ziel === 0 ? KEINE : VERLAUF[s.ziel - 1]; + 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; }; @@ -176,13 +171,13 @@ const phaseFuer = (schritt: number): HeatmapPhase => { 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, - * bevor der Taktgeber geladen ist. */ +/** Anfangszustand (Grundbestand der Heatmap, nichts gewählt, Auswahl und Maus ohne Wirkung): damit zeigt die Bühne die + * Website samt Heatmap sofort, bevor der Taktgeber geladen ist. */ export const HEATMAP_START: HeatmapZustand = { phase: "aufbau", - fuellung: KEINE, - punkte: 0, - anteil: 0, + fuellung: BASIS, + punkte: zaehlePunkte(BASIS), + anteil: zaehlePunkte(BASIS) / SUMME_KLICKS, zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) }, auswahl: null, maus: false, @@ -198,7 +193,7 @@ export const HEATMAP_START: HeatmapZustand = { * Zustand der Heatmap-Demo. * laeuft = der Ablauf darf abspielen (Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt). * 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 => { const [schritt, setSchritt] = useState(0); diff --git a/src/test/demo-heatmap.test.tsx b/src/test/demo-heatmap.test.tsx index 8c470fd..1cce74c 100644 --- a/src/test/demo-heatmap.test.tsx +++ b/src/test/demo-heatmap.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, within, act, cleanup, fireEvent } from "@testing-library/react"; 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 HeatmapDiagnose from "@/components/home/demos/heatmap/HeatmapDiagnose"; import { @@ -221,7 +221,7 @@ describe("Website ist reine Dekoration", () => { }); 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(); const { container } = render(); expect(vi.getTimerCount()).toBe(0); @@ -229,7 +229,10 @@ describe("Ablauf", () => { const wurzel = container.querySelector(".hm-desktop") as HTMLElement; expect(wurzel).toHaveAttribute("data-phase", "aufbau"); 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"); }); @@ -245,7 +248,7 @@ describe("Ablauf", () => { vorspulen(3_000); const frueh = heat(); - expect(frueh).toBeGreaterThan(0); + expect(frueh).toBeGreaterThan(HEATMAP_START.punkte); expect(frueh).toBeLessThan(1284); expect(wurzel).toHaveAttribute("data-phase", "aufbau"); @@ -261,7 +264,7 @@ describe("Ablauf", () => { expect(halten).toBeLessThanOrEqual(6_300); spuleBis(wurzel, "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", () => { @@ -269,10 +272,10 @@ describe("Ablauf", () => { const { container, rerender } = render(); vorspulen(4_000); 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(); 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", () => {