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>
731 lines
27 KiB
TypeScript
731 lines
27 KiB
TypeScript
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react";
|
||
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";
|
||
|
||
/**
|
||
* Die erfundene Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2) mit der
|
||
* Heatmap darüber. 'desktop' wird in 1512 x 893 gesetzt (MacBook), 'handy' in 402 x 874 (iPhone, oben 59 und
|
||
* unten 34 frei). Die Seite ist reine Dekoration: aria-hidden, keine fokussierbaren Elemente.
|
||
* Alle Bilder sind selbst gezeichnete SVG/CSS-Grafiken, nichts wird nachgeladen.
|
||
*/
|
||
|
||
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
|
||
type Props = { zustand?: HeatmapZustand; variante: DemoVariante };
|
||
|
||
// ── Routenbild: selbst gezeichnete Kletterwand ────────────────────────────────
|
||
|
||
type Farbe = "lime" | "orange" | "pink" | "cyan";
|
||
const FARBEN: Record<Farbe, { hell: string; dunkel: string }> = {
|
||
lime: { hell: "#c6f432", dunkel: "#8aa81a" },
|
||
orange: { hell: "#ff7a1a", dunkel: "#b8500a" },
|
||
pink: { hell: "#ff4f9a", dunkel: "#b82a6b" },
|
||
cyan: { hell: "#2ad1ff", dunkel: "#0e86ad" },
|
||
};
|
||
|
||
/** Formen von Klettergriffen um den Nullpunkt (etwa 70 breit). */
|
||
const FORMEN: Record<string, string> = {
|
||
a: "M-30 8C-35-14-10-33 14-29C35-25 38-2 30 14C22 31-25 33-30 8Z",
|
||
b: "M-35 2C-31-25 31-27 35 1C37 22 21 30 0 30C-22 30-37 23-35 2Z",
|
||
c: "M-37-8Q0-24 37-8Q42 13 0 15Q-42 13-37-8Z",
|
||
d: "M0-27C16-27 28-15 28 0C28 15 16 27 0 27C-16 27-28 15-28 0C-28-15-16-27 0-27Z",
|
||
e: "M-25 20C-33-4-8-31 14-27C35-23 31 8 23 22C9 31-14 31-25 20Z",
|
||
};
|
||
|
||
/** [x, y, Form, Größe, Drehung]; x und y als Anteil der Wand (0 bis 1). */
|
||
type Griff = readonly [number, number, string, number, number];
|
||
const ROUTEN: { farbe: Farbe; grad: string; griffe: readonly Griff[] }[] = [
|
||
{
|
||
farbe: "lime",
|
||
grad: "4",
|
||
griffe: [
|
||
[0.14, 0.84, "b", 1.1, -8],
|
||
[0.22, 0.72, "a", 0.9, 20],
|
||
[0.13, 0.6, "c", 1, -15],
|
||
[0.24, 0.47, "e", 0.95, 35],
|
||
[0.15, 0.34, "a", 0.9, -30],
|
||
[0.26, 0.22, "d", 0.9, 0],
|
||
[0.18, 0.1, "b", 1.15, 10],
|
||
],
|
||
},
|
||
{
|
||
farbe: "orange",
|
||
grad: "5",
|
||
griffe: [
|
||
[0.36, 0.86, "c", 1, 10],
|
||
[0.44, 0.74, "a", 1, -20],
|
||
[0.34, 0.62, "b", 0.9, 15],
|
||
[0.47, 0.5, "e", 1, -40],
|
||
[0.38, 0.37, "d", 0.85, 0],
|
||
[0.5, 0.25, "a", 0.95, 25],
|
||
[0.42, 0.12, "b", 1.1, -5],
|
||
],
|
||
},
|
||
{
|
||
farbe: "pink",
|
||
grad: "6",
|
||
griffe: [
|
||
[0.6, 0.82, "a", 1, 5],
|
||
[0.68, 0.71, "e", 0.9, -25],
|
||
[0.57, 0.58, "c", 1.05, 20],
|
||
[0.7, 0.46, "b", 1, -10],
|
||
[0.61, 0.34, "a", 0.9, 40],
|
||
[0.73, 0.22, "d", 0.9, 0],
|
||
[0.64, 0.1, "c", 1.1, -12],
|
||
],
|
||
},
|
||
{
|
||
farbe: "cyan",
|
||
grad: "7",
|
||
griffe: [
|
||
[0.83, 0.85, "b", 1, -5],
|
||
[0.9, 0.72, "a", 0.9, 30],
|
||
[0.79, 0.6, "e", 1, -15],
|
||
[0.91, 0.48, "c", 0.95, 10],
|
||
[0.8, 0.36, "a", 1, -35],
|
||
[0.9, 0.24, "b", 0.9, 15],
|
||
[0.84, 0.11, "d", 1.1, 0],
|
||
],
|
||
},
|
||
];
|
||
|
||
/** Kleine graue Tritte zwischen den Routen: [x, y, Form, Größe, Drehung]. */
|
||
const TRITTE: readonly Griff[] = [
|
||
[0.06, 0.66, "d", 0.4, 0],
|
||
[0.31, 0.8, "c", 0.45, 20],
|
||
[0.52, 0.64, "d", 0.4, 0],
|
||
[0.47, 0.88, "d", 0.42, 0],
|
||
[0.77, 0.77, "c", 0.45, -15],
|
||
[0.96, 0.62, "d", 0.4, 0],
|
||
[0.31, 0.5, "d", 0.38, 0],
|
||
[0.55, 0.4, "c", 0.45, 30],
|
||
[0.74, 0.4, "d", 0.38, 0],
|
||
[0.07, 0.4, "c", 0.45, -20],
|
||
[0.34, 0.2, "d", 0.38, 0],
|
||
[0.56, 0.2, "d", 0.4, 0],
|
||
[0.96, 0.34, "c", 0.45, 25],
|
||
[0.06, 0.2, "d", 0.38, 0],
|
||
];
|
||
|
||
const Griffe = ({ griff, farbe, mass }: { griff: Griff; farbe: Farbe | "grau"; mass: number }) => {
|
||
const [x, y, form, groesse, drehung] = griff;
|
||
const f = farbe === "grau" ? { hell: "#b9b6ae", dunkel: "#7d7a73" } : FARBEN[farbe];
|
||
const pfad = FORMEN[form];
|
||
const g = groesse * mass;
|
||
return (
|
||
<g transform={`translate(${x} ${y})`}>
|
||
<path d={pfad} transform={`translate(${5 * g} ${8 * g}) rotate(${drehung}) scale(${g})`} fill="#000" opacity="0.38" />
|
||
<g transform={`rotate(${drehung}) scale(${g})`}>
|
||
<path d={pfad} fill={f.dunkel} transform="translate(0 4)" />
|
||
<path d={pfad} fill={f.hell} />
|
||
<ellipse cx="-9" cy="-10" rx="13" ry="6" fill="#fff" opacity="0.38" transform="rotate(-24)" />
|
||
</g>
|
||
</g>
|
||
);
|
||
};
|
||
|
||
/** Die Wand als SVG. b und h sind die Maße der Zeichnung, `klein` ist die Handy-Fassung (weniger, kleinere Griffe). */
|
||
const Routenwand = ({ b, h, klein }: { b: number; h: number; klein?: boolean }) => {
|
||
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||
// flache Wand (Desktop, 260 hoch): kleinere Griffe, sonst drängen sie sich
|
||
const mass = klein ? 0.5 : h < 300 ? 0.68 : 1;
|
||
const px = (x: number) => x * b;
|
||
const py = (y: number) => h * (0.17 + (y - 0.1) * 0.9);
|
||
const matte = h * 0.93;
|
||
return (
|
||
<svg
|
||
className="hm-wand__svg"
|
||
viewBox={`0 0 ${b} ${h}`}
|
||
preserveAspectRatio="xMidYMid slice"
|
||
focusable="false"
|
||
aria-hidden="true"
|
||
>
|
||
<defs>
|
||
<linearGradient id={`${id}w`} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0" stopColor="#3b3b41" />
|
||
<stop offset="1" stopColor="#222226" />
|
||
</linearGradient>
|
||
<pattern id={`${id}p`} width={klein ? 16 : 32} height={klein ? 16 : 32} patternUnits="userSpaceOnUse">
|
||
<circle cx={klein ? 8 : 16} cy={klein ? 8 : 16} r={klein ? 1.3 : 2.6} fill="#151517" />
|
||
</pattern>
|
||
</defs>
|
||
<rect width={b} height={h} fill={`url(#${id}w)`} />
|
||
<rect width={b} height={h} fill={`url(#${id}p)`} opacity="0.9" />
|
||
{/* Plattenfugen */}
|
||
<g stroke="#141416" strokeWidth={klein ? 1.5 : 3}>
|
||
<path d={`M${b * 0.25} 0V${matte}M${b * 0.5} 0V${matte}M${b * 0.75} 0V${matte}M0 ${h * 0.5}H${b}`} />
|
||
</g>
|
||
<g stroke="#4a4a52" strokeWidth={klein ? 0.8 : 1.5} opacity="0.6">
|
||
<path
|
||
d={`M${b * 0.25 + 3} 0V${matte}M${b * 0.5 + 3} 0V${matte}M${b * 0.75 + 3} 0V${matte}M0 ${h * 0.5 + 3}H${b}`}
|
||
/>
|
||
</g>
|
||
{/* Dach: die Wand hängt über */}
|
||
<path d={`M0 0H${b}V${h * 0.05}L0 ${h * 0.12}Z`} fill="#18181b" />
|
||
<path d={`M0 ${h * 0.12}L${b} ${h * 0.05}`} stroke="#4a4a52" strokeWidth={klein ? 1 : 2} />
|
||
{/* Matte */}
|
||
<rect y={matte} width={b} height={h - matte} fill="#1b2633" />
|
||
<path d={`M0 ${matte}H${b}`} stroke="#35506b" strokeWidth={klein ? 1.5 : 3} />
|
||
<path
|
||
d={`M${b * 0.2} ${matte}V${h}M${b * 0.4} ${matte}V${h}M${b * 0.6} ${matte}V${h}M${b * 0.8} ${matte}V${h}`}
|
||
stroke="#121a24"
|
||
strokeWidth={klein ? 1 : 2}
|
||
/>
|
||
|
||
{/* Tritte */}
|
||
{TRITTE.filter((_, i) => !klein || i % 2 === 0).map((t, i) => (
|
||
<Griffe key={`t${i}`} griff={[px(t[0]), py(t[1]), t[2], t[3], t[4]]} farbe="grau" mass={mass} />
|
||
))}
|
||
|
||
{ROUTEN.map((r) => {
|
||
const griffe = klein ? r.griffe.filter((_, i) => i % 2 === 0 || i === r.griffe.length - 1) : r.griffe;
|
||
const start = griffe[0];
|
||
const oben = griffe[griffe.length - 1];
|
||
return (
|
||
<g key={r.farbe}>
|
||
{griffe.map((g, i) => (
|
||
<g key={i}>
|
||
{/* Kreidespur */}
|
||
<ellipse
|
||
cx={px(g[0]) - 8 * mass}
|
||
cy={py(g[1]) - 14 * mass}
|
||
rx={30 * mass}
|
||
ry={15 * mass}
|
||
fill="#fff"
|
||
opacity="0.07"
|
||
/>
|
||
<Griffe griff={[px(g[0]), py(g[1]), g[2], g[3], g[4]]} farbe={r.farbe} mass={mass} />
|
||
</g>
|
||
))}
|
||
{/* Schwierigkeit am Startgriff */}
|
||
<g transform={`translate(${px(start[0]) - 54 * mass} ${py(start[1]) + 6 * mass})`}>
|
||
<circle r={19 * mass} fill="#f3efe6" />
|
||
<circle r={19 * mass} fill="none" stroke={FARBEN[r.farbe].hell} strokeWidth={5 * mass} />
|
||
<text
|
||
textAnchor="middle"
|
||
dominantBaseline="central"
|
||
fontSize={22 * mass}
|
||
fontWeight="700"
|
||
fill="#141414"
|
||
fontFamily="inherit"
|
||
>
|
||
{r.grad}
|
||
</text>
|
||
</g>
|
||
{/* Ring um den Zielgriff */}
|
||
<circle
|
||
cx={px(oben[0])}
|
||
cy={py(oben[1])}
|
||
r={46 * mass}
|
||
fill="none"
|
||
stroke={FARBEN[r.farbe].hell}
|
||
strokeWidth={3 * mass}
|
||
strokeDasharray={`${9 * mass} ${9 * mass}`}
|
||
opacity="0.8"
|
||
/>
|
||
</g>
|
||
);
|
||
})}
|
||
<rect width={b} height={h} fill="none" />
|
||
</svg>
|
||
);
|
||
};
|
||
|
||
// ── Kleine Zeichnungen ───────────────────────────────────────────────────────
|
||
|
||
const LogoZeichen = () => (
|
||
<svg className="hm-logo__zeichen" viewBox="0 0 56 56" aria-hidden="true" focusable="false">
|
||
<rect width="56" height="56" rx="16" fill="#c6f432" />
|
||
<path d="M13 44V22L33 11L44 19V44Z" fill="#141414" />
|
||
<path d="M13 22L33 11" stroke="#c6f432" strokeWidth="2" opacity="0.5" />
|
||
<circle cx="25" cy="33" r="4.5" fill="#ff7a1a" />
|
||
<circle cx="35" cy="24" r="3.6" fill="#ff4f9a" />
|
||
<circle cx="33" cy="38" r="3.4" fill="#2ad1ff" />
|
||
</svg>
|
||
);
|
||
|
||
const Pfeil = () => (
|
||
<svg className="hm-pfeil" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
|
||
</svg>
|
||
);
|
||
|
||
const Handset = () => (
|
||
<svg className="hm-tel__symbol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<rect x="6.5" y="2.5" width="11" height="19" rx="3" fill="none" stroke="currentColor" strokeWidth="2" />
|
||
<path d="M10.5 18.5h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
// ── Messen und Klickpunkte ────────────────────────────────────────────────────
|
||
|
||
type Rechtecke = Record<string, PunktRechteck>;
|
||
|
||
const gleich = (a: Rechtecke, b: Rechtecke) =>
|
||
Object.keys(b).length === Object.keys(a).length &&
|
||
Object.entries(b).every(([id, r]) => {
|
||
const alt = a[id];
|
||
return (
|
||
alt && Math.abs(alt.x - r.x) < 0.5 && Math.abs(alt.y - r.y) < 0.5 && Math.abs(alt.b - r.b) < 0.5 && Math.abs(alt.h - r.h) < 0.5
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Misst die Zonen-Rechtecke relativ zur Fläche in Designpunkten. getBoundingClientRect liefert Bildschirmpunkte;
|
||
* der Maßstab des Rahmens (transform: scale) ergibt sich aus Bildschirmbreite / Layoutbreite der Fläche.
|
||
* Ohne Messung (jsdom, ausgeblendet) gelten die festen Ersatz-Rechtecke.
|
||
*/
|
||
const useGemessen = (ref: RefObject<HTMLElement | null>, variante: DemoVariante) => {
|
||
const [rechtecke, setRechtecke] = useState<Rechtecke>(ZONEN_LAYOUT[variante]);
|
||
useLayoutEffect(() => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
let aktiv = true;
|
||
const messe = () => {
|
||
if (!aktiv) return;
|
||
const fl = el.getBoundingClientRect();
|
||
if (!fl.width || !el.offsetWidth) return;
|
||
const k = fl.width / el.offsetWidth;
|
||
const neu: Rechtecke = {};
|
||
el.querySelectorAll<HTMLElement>("[data-zone]").forEach((z) => {
|
||
const r = z.getBoundingClientRect();
|
||
neu[z.dataset.zone as string] = {
|
||
x: (r.left - fl.left) / k,
|
||
y: (r.top - fl.top) / k,
|
||
b: r.width / k,
|
||
h: r.height / k,
|
||
};
|
||
});
|
||
if (Object.keys(neu).length === 0) return;
|
||
setRechtecke((alt) => (gleich(alt, neu) ? alt : neu));
|
||
};
|
||
messe();
|
||
const beobachter = typeof ResizeObserver === "function" ? new ResizeObserver(messe) : null;
|
||
beobachter?.observe(el);
|
||
// Die Schrift lädt später und verschiebt Zeilenumbrüche: dann neu messen
|
||
document.fonts?.ready.then(messe).catch(() => {});
|
||
window.addEventListener("resize", messe);
|
||
return () => {
|
||
aktiv = false;
|
||
beobachter?.disconnect();
|
||
window.removeEventListener("resize", messe);
|
||
};
|
||
}, [ref, variante]);
|
||
return rechtecke;
|
||
};
|
||
|
||
/**
|
||
* Wie viele Punkte eine Zone bekommt. Die Skala der Heatmap sättigt schon bei zwei, drei überlagerten Flecken (rot);
|
||
* die Dichte steigt darum steil mit dem Klickanteil (Tageskarte = 1, nur sie wird durchgehend rot).
|
||
* Jede Zone bekommt aber mindestens so viele Punkte, dass ihre Klicks sichtbar sind (Wunsch des Inhabers 2026-10-04:
|
||
* nicht nur Punkte auf „Tageskarte kaufen“, auch z. B. die Telefonnummer). Große Zonen bekommen mehr Punkte.
|
||
*/
|
||
const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
|
||
const anteil = zone.klicks / TOP_ZONE.klicks;
|
||
// Fläche eines Flecks in Designpunkten (Leinwand = halbe Fläche, der Radius gilt in Leinwandpunkten)
|
||
const fleck = Math.PI * (2 * LEINWAND[variante].radius) ** 2;
|
||
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.8 * 8 * flecke)));
|
||
};
|
||
|
||
/** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
|
||
const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record<string, HeatPunkt[]> => {
|
||
const { breite, hoehe } = FLAECHE_GROESSE[variante];
|
||
const aus: Record<string, HeatPunkt[]> = {};
|
||
ZONEN.forEach((zone, i) => {
|
||
const r = rechtecke[zone.id] ?? ZONEN_LAYOUT[variante][zone.id];
|
||
const rand = Math.min(r.b, r.h) * 0.3;
|
||
const zufallsfolge = zufall(1000 + i * 17 + (variante === "handy" ? 5 : 0));
|
||
const anzahl = punktAnzahl(zone, r, variante);
|
||
const kern = Math.round(anzahl * 0.85);
|
||
const innen = punkteInZone({ x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe }, kern, zufallsfolge);
|
||
const streu = punkteInZone(
|
||
{
|
||
x: (r.x - rand) / breite,
|
||
y: (r.y - rand) / hoehe,
|
||
b: (r.b + 2 * rand) / breite,
|
||
h: (r.h + 2 * rand) / hoehe,
|
||
},
|
||
anzahl - kern,
|
||
zufallsfolge,
|
||
);
|
||
// mischen, damit jede Teilmenge (die ersten n Punkte) gleichmäßig über die Zone verteilt ist
|
||
const alle = [...innen, ...streu].map((p) => ({
|
||
x: Math.min(1, Math.max(0, p.x)),
|
||
y: Math.min(1, Math.max(0, p.y)),
|
||
}));
|
||
for (let n = alle.length - 1; n > 0; n--) {
|
||
const m = Math.floor(zufallsfolge() * (n + 1));
|
||
[alle[n], alle[m]] = [alle[m], alle[n]];
|
||
}
|
||
aus[zone.id] = alle;
|
||
});
|
||
return aus;
|
||
};
|
||
|
||
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
|
||
/** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 5 Punkte auf dem MacBook). */
|
||
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;
|
||
|
||
// ── Seite ───────────────────────────────────────────────────────────────────
|
||
|
||
const HeatmapSeite = ({ zustand = HEATMAP_START, variante }: Props) => {
|
||
const wurzel = useRef<HTMLDivElement>(null);
|
||
const leinwand = useRef<HTMLCanvasElement>(null);
|
||
const fein = useFeinerZeiger();
|
||
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[]>([]);
|
||
|
||
// Grundbestand (Klicks der letzten 30 Tage): neu zeichnen nur bei neuer Messung, nicht je Bild
|
||
useEffect(() => {
|
||
const c = leinwand.current;
|
||
if (!c) return;
|
||
ebene.current = heatEbene(c, LEINWAND[variante].radius);
|
||
const grund: HeatPunkt[] = [];
|
||
for (const z of ZONEN) {
|
||
const alle = punkte[z.id];
|
||
grund.push(...alle.slice(0, Math.round((fuellung[z.id] ?? 0) * alle.length)));
|
||
}
|
||
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;
|
||
};
|
||
const bewegt = (e: MouseEvent) => {
|
||
if (!zustand.maus) zustand.setzeMaus(true);
|
||
const id = zoneUnter(e);
|
||
if (id !== zustand.auswahl) zustand.waehle(id);
|
||
};
|
||
const verlassen = () => {
|
||
zustand.setzeMaus(false);
|
||
zustand.waehle(null);
|
||
};
|
||
const getippt = (e: MouseEvent) => {
|
||
const id = zoneUnter(e);
|
||
// Maus: wählen. Finger: wählen, nochmal tippen hebt die Auswahl auf.
|
||
zustand.waehle(!fein && id !== null && id === zustand.auswahl ? null : id);
|
||
};
|
||
|
||
const gewaehlt = zoneNach(zustand.auswahl);
|
||
const r = gewaehlt ? rechtecke[gewaehlt.id] : undefined;
|
||
const { breite } = FLAECHE_GROESSE[variante];
|
||
const zeiger = zustand.zeiger[variante];
|
||
// Schildchen über der Zone; im Kopf der Seite und auf dem Handy in der oberen Hälfte darunter (sonst verdeckt es die Überschrift)
|
||
// Große Zonen (das Routenbild) tragen das Schildchen im Inneren, sonst würde es Nachbarn verdecken.
|
||
const innen = r ? r.h >= (variante === "desktop" ? 250 : 140) : false;
|
||
const unten = !innen && r ? (variante === "desktop" ? r.y < 80 : r.y < 440) : false;
|
||
const lage = innen ? "innen" : unten ? "unter" : "ueber";
|
||
|
||
return (
|
||
<div
|
||
ref={wurzel}
|
||
className={`hm hm-seite hm-${variante}`}
|
||
data-variante={variante}
|
||
data-phase={zustand.phase}
|
||
data-laeuft={zustand.laeuft ? "ja" : "nein"}
|
||
aria-hidden="true"
|
||
onMouseMove={fein ? bewegt : undefined}
|
||
onMouseLeave={fein ? verlassen : undefined}
|
||
onClick={getippt}
|
||
>
|
||
{variante === "desktop" ? <Desktop /> : <Handy />}
|
||
|
||
<canvas
|
||
ref={leinwand}
|
||
className="hm-heat"
|
||
width={LEINWAND[variante].b}
|
||
height={LEINWAND[variante].h}
|
||
data-anteil={zustand.punkte}
|
||
/>
|
||
|
||
{gewaehlt && r ? (
|
||
<>
|
||
<div
|
||
key={`rahmen-${gewaehlt.id}`}
|
||
className="hm-rahmen"
|
||
style={{ left: r.x, top: r.y, width: r.b, height: r.h }}
|
||
/>
|
||
<div
|
||
key={`schild-${gewaehlt.id}`}
|
||
className="hm-schild"
|
||
data-lage={lage}
|
||
style={{
|
||
top: innen ? r.y + (variante === "desktop" ? 28 : 10) : unten ? r.y + r.h + 14 : r.y - 14,
|
||
...(r.x + r.b / 2 > breite * 0.55
|
||
? { right: breite - (r.x + r.b) + (innen ? (variante === "desktop" ? 28 : 10) : 0) }
|
||
: { left: r.x + (innen ? (variante === "desktop" ? 28 : 10) : 0) }),
|
||
}}
|
||
>
|
||
{schildText(gewaehlt)}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
{statisch ? null : (
|
||
<DemoZeiger
|
||
x={zeiger.x}
|
||
y={zeiger.y}
|
||
klick={zeiger.klick}
|
||
sichtbar={zeiger.sichtbar}
|
||
art={variante === "handy" ? "finger" : "maus"}
|
||
groesse={variante === "handy" ? 1 : 1.9}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ── Desktop-Fassung (1512 x 893) ─────────────────────────────────────────────
|
||
|
||
const Desktop = () => (
|
||
<>
|
||
<div className="hm-kopf">
|
||
<div className="hm-logo" data-zone="logo">
|
||
<LogoZeichen />
|
||
<span className="hm-logo__text">
|
||
<span className="hm-logo__klein">Boulderhalle</span>
|
||
<span className="hm-logo__gross">Überhang</span>
|
||
</span>
|
||
</div>
|
||
<div className="hm-nav">
|
||
<span className="hm-nav__punkt" data-zone="nav-routen">
|
||
Routen
|
||
</span>
|
||
<span className="hm-nav__punkt" data-zone="nav-kurse">
|
||
Kurse
|
||
</span>
|
||
<span className="hm-nav__punkt">Preise</span>
|
||
<span className="hm-nav__punkt">Öffnungszeiten</span>
|
||
</div>
|
||
<div className="hm-tel" data-zone="telefon">
|
||
<Handset />
|
||
<span>0221 4710 142</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hm-held">
|
||
<div className="hm-held__vor">
|
||
<span className="hm-held__punkt" />
|
||
Frisch geschraubt
|
||
</div>
|
||
<div className="hm-held__titel">
|
||
<span>
|
||
Heute <em>14</em>
|
||
</span>
|
||
<span>neue Boulder</span>
|
||
</div>
|
||
<div className="hm-knopf" data-zone="tageskarte">
|
||
<span>Tageskarte kaufen</span>
|
||
<Pfeil />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hm-wand" data-zone="routenbild">
|
||
<Routenwand b={640} h={260} />
|
||
</div>
|
||
|
||
{/* Öffnungszeiten liegen unter der Wand und links vom iPhone der Bühne (das deckt x > 1158 / y > 419 der Fläche) */}
|
||
<div className="hm-karte hm-oeffnung" data-zone="oeffnungszeiten">
|
||
<div className="hm-karte__titel">Öffnungszeiten</div>
|
||
<div className="hm-preise">
|
||
<div>
|
||
<span>Mo–Fr</span>
|
||
<b>10–23 Uhr</b>
|
||
</div>
|
||
<div>
|
||
<span>Sa, So</span>
|
||
<b>9–21 Uhr</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hm-karten">
|
||
<div className="hm-karte hm-karte--live" data-zone="auslastung">
|
||
<div className="hm-karte__kopf">
|
||
<span className="hm-live" />
|
||
Live-Auslastung
|
||
</div>
|
||
<div className="hm-zahl">
|
||
42<span>%</span>
|
||
</div>
|
||
<div className="hm-balken">
|
||
<span style={{ width: "42%" }} />
|
||
</div>
|
||
<div className="hm-karte__text">Gerade viel Platz an der Wand.</div>
|
||
</div>
|
||
|
||
<div className="hm-karte hm-karte--kurs" data-zone="schnupperkurs">
|
||
<div className="hm-marke hm-marke--cyan">Für Einsteiger</div>
|
||
<div className="hm-karte__titel">Schnupperkurs</div>
|
||
<div className="hm-karte__text">Samstag, 10 Uhr, 90 Minuten. Leihschuhe sind dabei.</div>
|
||
</div>
|
||
|
||
<div className="hm-karte" data-zone="preise">
|
||
<div className="hm-karte__titel">Preise</div>
|
||
<div className="hm-preise">
|
||
<div>
|
||
<span>Tageskarte</span>
|
||
<b>14 €</b>
|
||
</div>
|
||
<div>
|
||
<span>10er-Karte</span>
|
||
<b>120 €</b>
|
||
</div>
|
||
<div>
|
||
<span>Monatskarte</span>
|
||
<b>59 €</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
// ── Handy-Fassung (402 x 874, oben 59 und unten 34 frei) ──────────────────────
|
||
|
||
const Handy = () => (
|
||
<>
|
||
<div className="hm-h-kopf">
|
||
<div className="hm-logo" data-zone="logo">
|
||
<LogoZeichen />
|
||
<span className="hm-logo__text">
|
||
<span className="hm-logo__klein">Boulderhalle</span>
|
||
<span className="hm-logo__gross">Überhang</span>
|
||
</span>
|
||
</div>
|
||
<div className="hm-tel" data-zone="telefon">
|
||
<Handset />
|
||
<span>0221 4710 142</span>
|
||
</div>
|
||
</div>
|
||
<div className="hm-h-nav">
|
||
<span className="hm-nav__punkt" data-zone="nav-routen">
|
||
Routen
|
||
</span>
|
||
<span className="hm-nav__punkt" data-zone="nav-kurse">
|
||
Kurse
|
||
</span>
|
||
<span className="hm-nav__punkt">Preise</span>
|
||
<span className="hm-nav__punkt">Zeiten</span>
|
||
</div>
|
||
|
||
<div className="hm-h-held">
|
||
<div className="hm-held__vor">
|
||
<span className="hm-held__punkt" />
|
||
Frisch geschraubt
|
||
</div>
|
||
<div className="hm-held__titel">
|
||
<span>
|
||
Heute <em>14</em>
|
||
</span>
|
||
<span>neue Boulder</span>
|
||
</div>
|
||
<div className="hm-knopf" data-zone="tageskarte">
|
||
<span>Tageskarte kaufen</span>
|
||
<Pfeil />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hm-h-live hm-karte hm-karte--live" data-zone="auslastung">
|
||
<div className="hm-zahl">
|
||
42<span>%</span>
|
||
</div>
|
||
<div className="hm-h-live__rechts">
|
||
<div className="hm-karte__kopf">
|
||
<span className="hm-live" />
|
||
Live-Auslastung
|
||
</div>
|
||
<div className="hm-balken">
|
||
<span style={{ width: "42%" }} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="hm-wand" data-zone="routenbild">
|
||
<Routenwand b={362} h={150} klein />
|
||
</div>
|
||
|
||
<div className="hm-karte hm-karte--kurs hm-h-kurs" data-zone="schnupperkurs">
|
||
<div className="hm-karte__titel">Schnupperkurs</div>
|
||
<div className="hm-karte__text">Sa 10 Uhr, Leihschuhe dabei</div>
|
||
</div>
|
||
|
||
<div className="hm-h-reihe">
|
||
<div className="hm-karte" data-zone="preise">
|
||
<div className="hm-karte__titel">Preise</div>
|
||
<div className="hm-preise">
|
||
<div>
|
||
<span>Tageskarte</span>
|
||
<b>14 €</b>
|
||
</div>
|
||
<div>
|
||
<span>Monat</span>
|
||
<b>59 €</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="hm-karte" data-zone="oeffnungszeiten">
|
||
<div className="hm-karte__titel">Geöffnet</div>
|
||
<div className="hm-preise">
|
||
<div>
|
||
<span>Mo–Fr</span>
|
||
<b>10–23</b>
|
||
</div>
|
||
<div>
|
||
<span>Sa, So</span>
|
||
<b>9–21</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
export default HeatmapSeite;
|
||
export { HeatmapSeite };
|