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:
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user