Kundenportal-Bühne: Terminplaner, Heatmap und Pinnwand als Live-Demos statt „Funktionen“
Die Station „Funktionen“ ist durch drei Stationen ersetzt (Gruppe „Funktionen zum Dazubuchen“). Auf dem MacBook läuft je eine erfundene Kunden-Website als Live-Komponente, auf dem iPhone die Portal-Ansicht der Funktion (echter Portal-Code, Fake-Daten): - Terminplaner (Salon Schnittstelle): Buchung im Widget, Bestätigung per Link, Eintrag im Kalender-Abo, Mitteilung „Neue Buchung …“ auf dem Handy - Website-Analyse (Boulderhalle Überhang): simulierte Klicks bauen die Heatmap auf (Algorithmus wie analytics/client/h.js); Maus über der Seite zeigt Zone + Klickzahl, darunter die Diagnose mit Rangliste und Satz (Beispieldaten) - News-Pinnwand (Ortsgemeinde Musterbach): „Aufhängen“ im Portal, Zettel fällt auf die Pinnwand Demos laden als eigene Chunks, laufen nur bei aktiver, sichtbarer Station und ohne reduzierte Bewegung. Unter 900 px zeigen die Karten die Website in der Handy-Fassung. Spezifikation: docs/superpowers/specs/2026-10-04-funktionen-demos.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9ad9ba2b7
commit
72275db9fa
39 files changed
+6567
-75
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import type { FocusEvent } from "react";
|
||||
import { useFeinerZeiger } from "../gemeinsam";
|
||||
import { HEATMAP_START, type HeatmapZustand } from "./ablauf";
|
||||
import { SUMME_KLICKS, TOP_ZONE, anteil, rangliste, zoneNach } from "./zonen";
|
||||
import "./heatmap.css";
|
||||
|
||||
/**
|
||||
* Die Diagnose unter dem MacBook (Demo Website-Analyse, Spezifikation 3.2): die vier meistgeklickten Zonen mit
|
||||
* Balken und Prozent, darunter ein beobachtender Satz zur gewählten Zone (ohne Auswahl: zur meistgeklickten).
|
||||
* Normaler Seitenmaßstab mit den Seitentoken, funktioniert hell und dunkel. Die Zeilen sind Knöpfe:
|
||||
* Fokus, Hover oder Klick wählen die Zone, auf der Website erscheint dann der Rahmen.
|
||||
* `kompakt` ist für die Handy-Karte: Satz oben, nur die drei meistgeklickten Zonen.
|
||||
*/
|
||||
|
||||
type Props = { zustand?: HeatmapZustand; kompakt?: boolean };
|
||||
|
||||
const zahl = (n: number) => new Intl.NumberFormat("de-DE").format(n);
|
||||
|
||||
/** Tastaturfokus? (Mausklicks sollen nicht doppelt wählen.) Wo :focus-visible fehlt, zählt jeder Fokus. */
|
||||
const tastaturFokus = (el: HTMLElement) => {
|
||||
try {
|
||||
return el.matches(":focus-visible");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const HeatmapDiagnose = ({ zustand = HEATMAP_START, kompakt = false }: Props) => {
|
||||
const fein = useFeinerZeiger();
|
||||
const zeilen = rangliste(kompakt ? 3 : 4);
|
||||
const gewaehlt = zoneNach(zustand.auswahl) ?? TOP_ZONE;
|
||||
const spitze = zeilen[0].klicks;
|
||||
|
||||
return (
|
||||
<section className={`hmd${kompakt ? " hmd--kompakt" : ""}`} aria-label="Diagnose" data-auswahl={gewaehlt.id}>
|
||||
<div className="hmd__kopf">
|
||||
<p className="hmd__titel">Diagnose</p>
|
||||
<span className="hmd__quelle">{kompakt ? "Beispieldaten" : "letzte 30 Tage · Beispieldaten"}</span>
|
||||
<span className="hmd__summe">{zahl(SUMME_KLICKS)} Klicks</span>
|
||||
</div>
|
||||
<div className="hmd__koerper">
|
||||
<ul className="hmd__liste" aria-label="Meistgeklickte Stellen der Seite">
|
||||
{zeilen.map((z) => {
|
||||
const an = zustand.auswahl === z.id;
|
||||
return (
|
||||
<li key={z.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="hmd__zeile"
|
||||
aria-pressed={an}
|
||||
onClick={() => zustand.waehle(!fein && an ? null : z.id)}
|
||||
onFocus={(e: FocusEvent<HTMLButtonElement>) => {
|
||||
if (tastaturFokus(e.currentTarget)) zustand.waehle(z.id);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (an) zustand.waehle(null);
|
||||
}}
|
||||
onMouseEnter={fein ? () => zustand.waehle(z.id) : undefined}
|
||||
onMouseLeave={fein ? () => zustand.waehle(null) : undefined}
|
||||
>
|
||||
<span className="hmd__name">
|
||||
<span>{z.name}</span>
|
||||
{z.tot ? <span className="hmd__tot">toter Klick</span> : null}
|
||||
</span>
|
||||
<span className="hmd__spur" aria-hidden="true">
|
||||
<span className="hmd__balken" style={{ transform: `scaleX(${(z.klicks / spitze).toFixed(3)})` }} />
|
||||
</span>
|
||||
<span className="hmd__prozent">{anteil(z)}{"\u00a0"}%</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="hmd__rechts">
|
||||
<p className="hmd__satz" aria-live="polite" aria-atomic="true" data-tot={gewaehlt.tot ? "ja" : "nein"}>
|
||||
{gewaehlt.satz}
|
||||
</p>
|
||||
{/* Farbskala der Heatmap (wie in heat.ts: blau, grün, gelb, rot) */}
|
||||
<div className="hmd__legende" aria-hidden="true">
|
||||
<span>wenig</span>
|
||||
<span className="hmd__skala" />
|
||||
<span>viel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatmapDiagnose;
|
||||
export { HeatmapDiagnose };
|
||||
@@ -0,0 +1,691 @@
|
||||
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 { 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);
|
||||
* damit die Farbe die Rangfolge zeigt, steigt die Dichte mit dem Quadrat des Klickanteils (Tageskarte = 1):
|
||||
* nur die meistgeklickte Stelle wird durchgehend rot, Menü und Telefon bleiben gelb bis grün.
|
||||
* Große Zonen bekommen mehr Punkte, damit ihre Fläche nicht leer wirkt.
|
||||
*/
|
||||
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]));
|
||||
return Math.min(obergrenze, Math.max(1, Math.round(anteil ** 2 * 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;
|
||||
|
||||
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;
|
||||
|
||||
// Neu zeichnen nur, wenn sich die sichtbaren Punkte ändern (neuer Klick, neue Messung), nicht je Bild
|
||||
useEffect(() => {
|
||||
const c = leinwand.current;
|
||||
if (!c) return;
|
||||
const sichtbar: 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));
|
||||
}
|
||||
zeichneHeatmap(c, sichtbar, LEINWAND[variante].radius);
|
||||
}, [punkte, fuellung, variante]);
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Ablauf der Demo „Website-Analyse“: ein simulierter Zeiger klickt nacheinander Stellen der Beispiel-Website an,
|
||||
* die Heatmap wird mit jedem Klick dichter, ist nach ca. 10 s 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.
|
||||
* Diese Datei wird sofort geladen und darf kein CSS importieren. Es laufen nur setTimeout-Ketten, kein rAF,
|
||||
* und nichts, solange `laeuft` false ist.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { DemoVariante } from "../gemeinsam";
|
||||
import { SUMME_KLICKS, TOP_ZONE, ZONEN, zonenPunkt, zoneNach } from "./zonen";
|
||||
|
||||
export type HeatmapPhase = "aufbau" | "voll" | "ausblenden";
|
||||
|
||||
export type HeatmapZeiger = { x: number; y: number; klick: number; sichtbar: boolean };
|
||||
|
||||
export type HeatmapZustand = {
|
||||
phase: HeatmapPhase;
|
||||
/** Wie viele Klickpunkte je Zone sichtbar sind: 0 (keine) bis 1 (alle). */
|
||||
fuellung: Readonly<Record<string, number>>;
|
||||
/** Anzahl sichtbarer Klickpunkte insgesamt (von 1.284) und ihr Anteil 0 bis 1. */
|
||||
punkte: number;
|
||||
anteil: number;
|
||||
/** Simulierter Zeiger je Fassung in Designpunkten der jeweiligen Fläche. */
|
||||
zeiger: Record<DemoVariante, HeatmapZeiger>;
|
||||
/** Gewählte Zone (Maus, Tippen, Rangliste) oder im statischen Zustand die meistgeklickte; sonst null. */
|
||||
auswahl: string | null;
|
||||
/** true, solange die Maus über der Website ist. */
|
||||
maus: boolean;
|
||||
/** Läuft der simulierte Ablauf gerade (wirklich, nicht pausiert)? */
|
||||
laeuft: boolean;
|
||||
statisch: boolean;
|
||||
waehle: (id: string | null) => void;
|
||||
setzeMaus: (an: boolean) => void;
|
||||
};
|
||||
|
||||
// ── Drehbuch ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Reihenfolge der angeklickten Zonen: häufig geklickte öfter (Tageskarte 3 x, Live-Auslastung und Routenbild 2 x). */
|
||||
export const KLICK_FOLGE: readonly string[] = [
|
||||
"tageskarte",
|
||||
"auslastung",
|
||||
"routenbild",
|
||||
"telefon",
|
||||
"tageskarte",
|
||||
"nav-kurse",
|
||||
"preise",
|
||||
"schnupperkurs",
|
||||
"routenbild",
|
||||
"oeffnungszeiten",
|
||||
"auslastung",
|
||||
"nav-routen",
|
||||
"logo",
|
||||
"tageskarte",
|
||||
];
|
||||
|
||||
/** Wo im Rechteck der Zeiger landet (Anteil von Breite und Höhe), damit nicht jeder Klick genau die Mitte trifft. */
|
||||
const ZIEL_VERSATZ: readonly [number, number][] = [
|
||||
[0.5, 0.5],
|
||||
[0.5, 0.35],
|
||||
[0.4, 0.55],
|
||||
[0.5, 0.5],
|
||||
[0.62, 0.45],
|
||||
[0.5, 0.5],
|
||||
[0.45, 0.4],
|
||||
[0.55, 0.5],
|
||||
[0.62, 0.4],
|
||||
[0.5, 0.45],
|
||||
[0.55, 0.6],
|
||||
[0.5, 0.5],
|
||||
[0.5, 0.5],
|
||||
[0.38, 0.55],
|
||||
];
|
||||
|
||||
const DAUER = { start: 450, flug: 560, klick: 130, voll: 6000, aus: 1000 } as const;
|
||||
|
||||
type Schritt =
|
||||
| { art: "start"; dauer: number }
|
||||
| { art: "flug" | "klick"; ziel: number; dauer: number }
|
||||
| { art: "voll" | "aus"; dauer: number };
|
||||
|
||||
const SCHRITTE: readonly Schritt[] = [
|
||||
{ art: "start", dauer: DAUER.start },
|
||||
...KLICK_FOLGE.flatMap((_, ziel): Schritt[] => [
|
||||
{ art: "flug", ziel, dauer: DAUER.flug },
|
||||
{ art: "klick", ziel, dauer: DAUER.klick },
|
||||
]),
|
||||
{ art: "voll", dauer: DAUER.voll },
|
||||
{ art: "aus", dauer: DAUER.aus },
|
||||
];
|
||||
const VOLL_START = SCHRITTE.findIndex((s) => s.art === "voll");
|
||||
|
||||
/** Dauer des Aufbaus bis „voll“ in Millisekunden (für Tests und Doku). */
|
||||
export const AUFBAU_DAUER = SCHRITTE.slice(0, VOLL_START).reduce((summe, s) => summe + s.dauer, 0);
|
||||
|
||||
// ── Füllung der Heatmap nach jedem Klick ───────────────────────────────────
|
||||
|
||||
const KEINE: Readonly<Record<string, number>> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 0])));
|
||||
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);
|
||||
* 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.
|
||||
*/
|
||||
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> = 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;
|
||||
};
|
||||
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);
|
||||
}
|
||||
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>>;
|
||||
});
|
||||
})();
|
||||
|
||||
const zaehlePunkte = (fuellung: Readonly<Record<string, number>>) =>
|
||||
ZONEN.reduce((summe, z) => summe + Math.round((fuellung[z.id] ?? 0) * z.klicks), 0);
|
||||
|
||||
// ── Zeigerziele ──────────────────────────────────────────────────────────────
|
||||
|
||||
const START_POSITION: Record<DemoVariante, { x: number; y: number }> = {
|
||||
desktop: { x: 1180, y: 760 },
|
||||
handy: { x: 300, y: 700 },
|
||||
};
|
||||
|
||||
const ziele = (variante: DemoVariante) =>
|
||||
KLICK_FOLGE.map((id, i) => zonenPunkt(variante, id, ZIEL_VERSATZ[i][0], ZIEL_VERSATZ[i][1]));
|
||||
|
||||
const ZIELE: Record<DemoVariante, { x: number; y: number }[]> = { desktop: ziele("desktop"), handy: ziele("handy") };
|
||||
|
||||
// ── Ableitung aus dem Schritt ──────────────────────────────────────────────────
|
||||
|
||||
const aus = (schritt: number) => SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)];
|
||||
|
||||
const zeigerFuer = (variante: DemoVariante, schritt: number, aktiv: boolean): HeatmapZeiger => {
|
||||
const s = aus(schritt);
|
||||
if (s.art === "start") return { ...START_POSITION[variante], klick: 0, sichtbar: aktiv };
|
||||
if (s.art === "flug") {
|
||||
const p = ZIELE[variante][s.ziel];
|
||||
return { ...p, klick: s.ziel, sichtbar: aktiv };
|
||||
}
|
||||
if (s.art === "klick") {
|
||||
const p = ZIELE[variante][s.ziel];
|
||||
return { ...p, klick: s.ziel + 1, sichtbar: aktiv };
|
||||
}
|
||||
// „voll“ und „ausblenden“: der Zeiger bleibt am letzten Ziel, wird aber unsichtbar
|
||||
const letzte = ZIELE[variante][KLICK_FOLGE.length - 1];
|
||||
return { ...letzte, klick: KLICK_FOLGE.length, sichtbar: false };
|
||||
};
|
||||
|
||||
const fuellungFuer = (schritt: number): Readonly<Record<string, number>> => {
|
||||
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 === "klick") return VERLAUF[s.ziel];
|
||||
return ALLE;
|
||||
};
|
||||
|
||||
const phaseFuer = (schritt: number): HeatmapPhase => {
|
||||
const art = aus(schritt).art;
|
||||
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. */
|
||||
export const HEATMAP_START: HeatmapZustand = {
|
||||
phase: "aufbau",
|
||||
fuellung: KEINE,
|
||||
punkte: 0,
|
||||
anteil: 0,
|
||||
zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) },
|
||||
auswahl: null,
|
||||
maus: false,
|
||||
laeuft: false,
|
||||
statisch: false,
|
||||
waehle: () => {},
|
||||
setzeMaus: () => {},
|
||||
};
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export const useHeatmapAblauf = (laeuft: boolean, statisch: boolean): HeatmapZustand => {
|
||||
const [schritt, setSchritt] = useState(0);
|
||||
const [maus, setMaus] = useState(false);
|
||||
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
|
||||
const interaktiv = maus || gewaehlt !== null;
|
||||
const spielt = laeuft && !statisch && !interaktiv;
|
||||
|
||||
useEffect(() => {
|
||||
if (!laeuft || statisch) {
|
||||
setSchritt((s) => (s === 0 ? s : 0));
|
||||
return;
|
||||
}
|
||||
if (interaktiv) return;
|
||||
const t = window.setTimeout(
|
||||
() => setSchritt((s) => (s >= SCHRITTE.length - 1 ? 0 : s + 1)),
|
||||
aus(schritt).dauer,
|
||||
);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [laeuft, statisch, interaktiv, schritt]);
|
||||
|
||||
const waehle = useCallback((id: string | null) => {
|
||||
const gueltig = id !== null && zoneNach(id) ? id : null;
|
||||
setGewaehlt(gueltig);
|
||||
// Wer etwas wählt, sieht die volle Heatmap; nach dem Loslassen geht es von dort aus weiter (nicht vom halben Aufbau)
|
||||
if (gueltig !== null) setSchritt((s) => (phaseFuer(s) === "aufbau" ? VOLL_START : s));
|
||||
}, []);
|
||||
|
||||
const setzeMaus = useCallback((an: boolean) => {
|
||||
setMaus(an);
|
||||
if (an) setSchritt((s) => (phaseFuer(s) === "aufbau" ? VOLL_START : s));
|
||||
}, []);
|
||||
|
||||
return useMemo<HeatmapZustand>(() => {
|
||||
const voll = statisch || interaktiv;
|
||||
const fuellung = voll ? ALLE : fuellungFuer(schritt);
|
||||
const punkte = voll ? SUMME_KLICKS : zaehlePunkte(fuellung);
|
||||
const zeigerAktiv = spielt && phaseFuer(schritt) === "aufbau";
|
||||
return {
|
||||
phase: voll ? "voll" : phaseFuer(schritt),
|
||||
fuellung,
|
||||
punkte,
|
||||
anteil: punkte / SUMME_KLICKS,
|
||||
zeiger: {
|
||||
desktop: zeigerFuer("desktop", schritt, zeigerAktiv),
|
||||
handy: zeigerFuer("handy", schritt, zeigerAktiv),
|
||||
},
|
||||
auswahl: gewaehlt ?? (statisch ? TOP_ZONE.id : null),
|
||||
maus,
|
||||
laeuft: spielt,
|
||||
statisch,
|
||||
waehle,
|
||||
setzeMaus,
|
||||
};
|
||||
}, [schritt, statisch, interaktiv, spielt, gewaehlt, maus, waehle, setzeMaus]);
|
||||
};
|
||||
@@ -0,0 +1,873 @@
|
||||
/*
|
||||
* Demo „Website-Analyse“: die Beispiel-Website „Boulderhalle Überhang“ (.hm-*) und die Diagnose darunter (.hmd-*).
|
||||
* Die Website wird in Designpunkten gesetzt (Desktop 1512 x 893, Handy 402 x 874) und vom Geräterahmen verkleinert,
|
||||
* darum große Schrift und große Flächen. Alle Maße in px = Designpunkte.
|
||||
* Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference, Hover nur bei feinem Zeiger.
|
||||
*/
|
||||
|
||||
/* ── Grundlage der Website ───────────────────────────────────────────────── */
|
||||
|
||||
.hm {
|
||||
--hm-bg: #141414;
|
||||
--hm-tafel: #1f1f1f;
|
||||
--hm-tafel-2: #272727;
|
||||
--hm-linie: #2c2c2c;
|
||||
--hm-kreide: #f3efe6;
|
||||
--hm-grau: #aaa59b;
|
||||
--hm-lime: #c6f432;
|
||||
--hm-orange: #ff7a1a;
|
||||
--hm-pink: #ff4f9a;
|
||||
--hm-cyan: #2ad1ff;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--hm-bg);
|
||||
color: var(--hm-kreide);
|
||||
font-family: var(--font-ui, "Instrument Sans", system-ui, sans-serif);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
text-align: left;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
.hm *,
|
||||
.hm *::before,
|
||||
.hm *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
.hm em {
|
||||
font-style: normal;
|
||||
}
|
||||
.hm svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.hm-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.hm-logo__zeichen {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: none;
|
||||
}
|
||||
.hm-logo__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.hm-logo__klein {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--hm-grau);
|
||||
line-height: 1;
|
||||
}
|
||||
.hm-logo__gross {
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Telefonnummer */
|
||||
.hm-tel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 64px;
|
||||
padding: 0 26px 0 22px;
|
||||
border: 2px solid #3a3a3a;
|
||||
border-radius: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-tel__symbol {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
|
||||
/* Navigationspunkte */
|
||||
.hm-nav__punkt {
|
||||
display: inline-block;
|
||||
padding: 10px 22px;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hauptknopf */
|
||||
.hm-knopf {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 88px;
|
||||
padding: 0 40px 0 48px;
|
||||
border-radius: 44px;
|
||||
background: var(--hm-lime);
|
||||
color: #141414;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-pfeil {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* Karten */
|
||||
.hm-karte {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px 26px;
|
||||
border-radius: 28px;
|
||||
background: var(--hm-tafel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-karte__titel {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.hm-karte__text {
|
||||
font-size: 23px;
|
||||
line-height: 1.3;
|
||||
color: var(--hm-grau);
|
||||
}
|
||||
.hm-karte__kopf {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
color: var(--hm-kreide);
|
||||
}
|
||||
.hm-live {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--hm-lime);
|
||||
flex: none;
|
||||
}
|
||||
.hm-zahl {
|
||||
font-size: 84px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.95;
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
.hm-zahl span {
|
||||
margin-left: 4px;
|
||||
font-size: 0.6em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.hm-balken {
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: #343434;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hm-balken span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 11px;
|
||||
background: var(--hm-lime);
|
||||
}
|
||||
.hm-marke {
|
||||
align-self: flex-start;
|
||||
padding: 6px 18px;
|
||||
border-radius: 999px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.hm-marke--cyan {
|
||||
background: var(--hm-cyan);
|
||||
color: #0b2530;
|
||||
}
|
||||
.hm-marke--lime {
|
||||
background: #2a3312;
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
.hm-preise {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-preise > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 25px;
|
||||
}
|
||||
.hm-preise span {
|
||||
color: var(--hm-grau);
|
||||
}
|
||||
.hm-preise b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Wand */
|
||||
.hm-wand {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: #2a2a2e;
|
||||
}
|
||||
.hm-wand__svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Desktop 1512 x 893 ──────────────────────────────────────────────────── */
|
||||
|
||||
.hm-desktop .hm-kopf {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 120px;
|
||||
padding: 0 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid var(--hm-linie);
|
||||
}
|
||||
.hm-desktop .hm-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: 80px;
|
||||
}
|
||||
.hm-desktop .hm-held {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 150px;
|
||||
width: 720px;
|
||||
}
|
||||
.hm-held__vor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--hm-kreide);
|
||||
}
|
||||
.hm-held__punkt {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--hm-orange);
|
||||
}
|
||||
.hm-held__titel {
|
||||
margin-top: 16px;
|
||||
font-size: 104px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.98;
|
||||
}
|
||||
.hm-held__titel span {
|
||||
display: block;
|
||||
}
|
||||
.hm-held__titel em {
|
||||
color: var(--hm-lime);
|
||||
}
|
||||
.hm-held__text {
|
||||
margin-top: 22px;
|
||||
margin-bottom: 28px;
|
||||
font-size: 30px;
|
||||
font-weight: 500;
|
||||
color: var(--hm-grau);
|
||||
}
|
||||
.hm-desktop .hm-knopf {
|
||||
margin-top: 80px;
|
||||
}
|
||||
/* Das iPhone der Bühne deckt die Fläche rechts unten ab (x > 1158, y > 419): Wand und Öffnungszeiten rechts oben,
|
||||
die drei Karten unten reichen nur bis x 1138. So bleibt jede Klickzone sichtbar und hoverbar. */
|
||||
.hm-desktop .hm-wand {
|
||||
position: absolute;
|
||||
left: 808px;
|
||||
top: 144px;
|
||||
width: 640px;
|
||||
height: 260px;
|
||||
}
|
||||
.hm-desktop .hm-oeffnung {
|
||||
position: absolute;
|
||||
left: 808px;
|
||||
top: 424px;
|
||||
width: 330px;
|
||||
height: 178px;
|
||||
padding: 24px 26px;
|
||||
gap: 14px;
|
||||
}
|
||||
.hm-desktop .hm-oeffnung .hm-karte__titel {
|
||||
font-size: 34px;
|
||||
}
|
||||
.hm-desktop .hm-karten {
|
||||
position: absolute;
|
||||
left: 64px;
|
||||
top: 620px;
|
||||
width: 1074px;
|
||||
height: 248px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
.hm-desktop .hm-karte--live {
|
||||
gap: 0;
|
||||
}
|
||||
.hm-desktop .hm-karte--live .hm-zahl {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hm-desktop .hm-karte--live .hm-balken {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.hm-desktop .hm-karte--live .hm-karte__text {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.hm-desktop .hm-karte--kurs {
|
||||
background: #16262c;
|
||||
gap: 12px;
|
||||
}
|
||||
.hm-desktop .hm-karten .hm-karte:nth-child(3) {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* ── Handy 402 x 874 (oben 59 / unten 34 frei) ───────────────────────────── */
|
||||
|
||||
.hm-handy .hm-h-kopf {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 115px;
|
||||
padding: 59px 16px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--hm-bg);
|
||||
}
|
||||
.hm-handy .hm-logo {
|
||||
gap: 10px;
|
||||
}
|
||||
.hm-handy .hm-logo__zeichen {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.hm-handy .hm-logo__klein {
|
||||
font-size: 12px;
|
||||
}
|
||||
.hm-handy .hm-logo__gross {
|
||||
font-size: 23px;
|
||||
}
|
||||
.hm-handy .hm-tel {
|
||||
height: 38px;
|
||||
gap: 7px;
|
||||
padding: 0 12px 0 10px;
|
||||
border-width: 1.5px;
|
||||
border-radius: 19px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.hm-handy .hm-tel__symbol {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.hm-handy .hm-h-nav {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 115px;
|
||||
height: 44px;
|
||||
padding: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1.5px solid var(--hm-linie);
|
||||
background: var(--hm-bg);
|
||||
}
|
||||
.hm-handy .hm-nav__punkt {
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hm-handy .hm-h-held {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 172px;
|
||||
}
|
||||
.hm-handy .hm-held__vor {
|
||||
font-size: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-handy .hm-held__punkt {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
.hm-handy .hm-held__titel {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 44px;
|
||||
line-height: 0.98;
|
||||
}
|
||||
.hm-handy .hm-knopf {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 58px;
|
||||
padding: 0 22px 0 26px;
|
||||
border-radius: 29px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.hm-handy .hm-pfeil {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.hm-handy .hm-karte {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.hm-handy .hm-karte__titel {
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.hm-handy .hm-karte__text {
|
||||
font-size: 15px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.hm-handy .hm-karte__kopf {
|
||||
font-size: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
.hm-handy .hm-live {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
.hm-handy .hm-h-live {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
top: 374px;
|
||||
height: 78px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.hm-handy .hm-zahl {
|
||||
font-size: 40px;
|
||||
}
|
||||
.hm-handy .hm-h-live__rechts {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hm-handy .hm-balken {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hm-handy .hm-balken span {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hm-handy .hm-wand {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 466px;
|
||||
width: 362px;
|
||||
height: 150px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.hm-handy .hm-h-kurs {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 630px;
|
||||
width: 362px;
|
||||
height: 66px;
|
||||
background: #16262c;
|
||||
justify-content: center;
|
||||
}
|
||||
.hm-handy .hm-h-reihe {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 710px;
|
||||
width: 362px;
|
||||
height: 118px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.hm-handy .hm-h-reihe .hm-karte {
|
||||
gap: 6px;
|
||||
}
|
||||
.hm-handy .hm-preise {
|
||||
gap: 4px;
|
||||
}
|
||||
.hm-handy .hm-preise > div {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ── Heatmap, Rahmen, Schildchen ─────────────────────────────────────────── */
|
||||
|
||||
.hm-heat {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.hm[data-phase="ausblenden"] .hm-heat {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hm-rahmen {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
border: 6px solid #fff;
|
||||
border-radius: 30px;
|
||||
box-shadow:
|
||||
0 0 0 4px #141414,
|
||||
0 0 0 10px rgb(0 0 0 / 0.18);
|
||||
margin: -12px 0 0 -12px;
|
||||
padding: 6px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.hm-handy .hm-rahmen {
|
||||
border-width: 3px;
|
||||
border-radius: 14px;
|
||||
margin: -6px 0 0 -6px;
|
||||
padding: 3px;
|
||||
box-shadow: 0 0 0 2px #141414;
|
||||
}
|
||||
.hm-schild {
|
||||
position: absolute;
|
||||
z-index: 21;
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
max-width: 760px;
|
||||
padding: 10px 24px 12px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
font-size: 31px;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.01em;
|
||||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.4);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.hm-schild[data-lage="unter"],
|
||||
.hm-schild[data-lage="innen"] {
|
||||
transform: none;
|
||||
}
|
||||
.hm-handy .hm-schild {
|
||||
max-width: 340px;
|
||||
padding: 7px 12px 8px;
|
||||
border-radius: 9px;
|
||||
font-size: 15px;
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* Der Zeiger gleitet auf dieser Seite etwas schneller als die Standardzeit (Takt: 560 ms Flug) */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hm .dz-zeiger {
|
||||
transition:
|
||||
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;
|
||||
}
|
||||
.hm[data-laeuft="ja"] .hm-live {
|
||||
animation: hm-puls 1.6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
@keyframes hm-ein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes hm-puls {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Diagnose (normaler Seitenmaßstab, Seitentoken) ──────────────────────── */
|
||||
|
||||
.hmd {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 12px 16px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-alt);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
.hmd *,
|
||||
.hmd *::before,
|
||||
.hmd *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.hmd__kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 0 0 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hmd__titel {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0;
|
||||
color: var(--fg);
|
||||
}
|
||||
.hmd__quelle {
|
||||
color: var(--fg-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hmd__summe {
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hmd__koerper {
|
||||
/* Platz für fünf Zeilen Satz: die Karte springt nicht, wenn die gewählte Zone wechselt */
|
||||
min-height: 122px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: 8px 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.hmd__liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hmd__zeile {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 56px 36px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: calc(100% + 16px);
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 8px;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hmd__zeile:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.hmd__zeile[aria-pressed="true"] {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.hmd__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hmd__name > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hmd__tot {
|
||||
flex: none;
|
||||
padding: 0 7px;
|
||||
border: 1px solid var(--accent-text);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-text);
|
||||
font-size: 14px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hmd__spur {
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--line);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hmd__balken {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: var(--line-strong);
|
||||
transform-origin: left center;
|
||||
}
|
||||
.hmd__zeile[aria-pressed="true"] .hmd__balken {
|
||||
background: var(--accent);
|
||||
}
|
||||
.hmd__prozent {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* rechts: Satz zur gewählten Zone, darunter die Farbskala. Die Spalte ist nie höher als die Liste, die Karte springt nicht. */
|
||||
.hmd__rechts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.hmd__legende {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 17px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.hmd__skala {
|
||||
flex: 0 0 64px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, rgb(0 0 255), rgb(0 255 0) 45%, rgb(255 255 0) 75%, rgb(255 0 0));
|
||||
}
|
||||
.hmd__satz {
|
||||
margin: 0;
|
||||
padding: 2px 0 2px 14px;
|
||||
border-left: 3px solid var(--accent);
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
color: var(--fg);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.hmd__satz[data-tot="ja"] {
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
/* kompakt: für die Handy-Karte, schmal (bis 320 px), Satz oben, drei Zeilen */
|
||||
.hmd--kompakt {
|
||||
max-width: 320px;
|
||||
padding: 12px 14px 12px;
|
||||
}
|
||||
.hmd--kompakt .hmd__kopf {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
.hmd--kompakt .hmd__koerper {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.hmd--kompakt .hmd__rechts {
|
||||
display: contents;
|
||||
}
|
||||
.hmd--kompakt .hmd__satz {
|
||||
order: -1;
|
||||
margin-bottom: 2px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.hmd--kompakt .hmd__legende {
|
||||
order: 1;
|
||||
padding-left: 0;
|
||||
}
|
||||
.hmd--kompakt .hmd__zeile {
|
||||
grid-template-columns: minmax(0, 1fr) 48px 36px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hmd__balken {
|
||||
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.hmd__zeile:hover {
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
}
|
||||
.hmd__zeile[aria-pressed="true"]:hover {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* niedrige Fenster (Laptops mit wenig Bildschirmhöhe): die Diagnose rückt enger zusammen, damit sie unter dem MacBook
|
||||
noch ins Bild passt */
|
||||
@media (max-height: 760px) {
|
||||
.hmd:not(.hmd--kompakt) {
|
||||
padding: 8px 14px 10px;
|
||||
}
|
||||
.hmd:not(.hmd--kompakt) .hmd__kopf {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hmd:not(.hmd--kompakt) .hmd__zeile {
|
||||
min-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Schmale Bühne (kleine Fenster): die Marke „toter Klick“ wird zum roten Punkt und der Balken kürzer, damit die Namen
|
||||
nicht abgeschnitten werden. Die Bühne (.kp-geraete) ist der Container. */
|
||||
@container (max-width: 729px) {
|
||||
.hmd:not(.hmd--kompakt) .hmd__tot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-text);
|
||||
font-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@container (max-width: 649px) {
|
||||
.hmd:not(.hmd--kompakt) .hmd__zeile {
|
||||
grid-template-columns: minmax(0, 1fr) 20px 36px;
|
||||
}
|
||||
}
|
||||
@media (max-height: 760px) {
|
||||
.hmd:not(.hmd--kompakt) .hmd__koerper {
|
||||
min-height: 102px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Klickzonen der erfundenen Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2).
|
||||
* Alle Zahlen sind Beispieldaten (letzte 30 Tage), die Summe ist 1.284 Klicks.
|
||||
* Diese Datei wird sofort geladen und darf kein CSS und keine Komponenten importieren.
|
||||
*/
|
||||
import type { ZonenRechteck } from "../heat";
|
||||
|
||||
export type DemoVarianteName = "desktop" | "handy";
|
||||
|
||||
/** Summe aller Klicks der Beispieldaten. */
|
||||
export const SUMME_KLICKS = 1284;
|
||||
|
||||
export type Zone = {
|
||||
id: string;
|
||||
/** So nennt ein Mensch die Stelle auf der Seite. */
|
||||
name: string;
|
||||
klicks: number;
|
||||
/** Toter Klick: Es wird oft geklickt, aber die Stelle ist kein Link. */
|
||||
tot: boolean;
|
||||
/** Beobachtung zu dieser Zone, nur beschreibend, ohne Versprechen. */
|
||||
satz: string;
|
||||
};
|
||||
|
||||
const prozent = (klicks: number) => Math.round((klicks / SUMME_KLICKS) * 100);
|
||||
|
||||
type Rohzone = { id: string; name: string; klicks: number; tot?: boolean; satz: (p: number) => string };
|
||||
|
||||
// Reihenfolge = Reihenfolge auf der Seite (oben links nach unten rechts)
|
||||
const ROH: Rohzone[] = [
|
||||
{
|
||||
id: "logo",
|
||||
name: "Logo",
|
||||
klicks: 32,
|
||||
satz: (p) => `Das Logo wird selten angeklickt – ${p}\u00a0% aller Klicks.`,
|
||||
},
|
||||
{
|
||||
id: "nav-routen",
|
||||
name: "Navigation „Routen“",
|
||||
klicks: 38,
|
||||
satz: (p) => `Im Menü liegt „Routen“ mit ${p}\u00a0% der Klicks hinter „Kurse“ auf Platz 2.`,
|
||||
},
|
||||
{
|
||||
id: "nav-kurse",
|
||||
name: "Navigation „Kurse“",
|
||||
klicks: 47,
|
||||
satz: (p) => `Im Menü wird „Kurse“ am häufigsten angeklickt – ${p}\u00a0% aller Klicks.`,
|
||||
},
|
||||
{
|
||||
id: "telefon",
|
||||
name: "Telefonnummer",
|
||||
klicks: 61,
|
||||
satz: (p) => `${p}\u00a0% der Klicks gehen auf die Telefonnummer im Kopf der Seite.`,
|
||||
},
|
||||
{
|
||||
id: "tageskarte",
|
||||
name: "Tageskarte kaufen",
|
||||
klicks: 402,
|
||||
satz: (p) => `Am meisten geklickt: „Tageskarte kaufen“ – ${p}\u00a0% aller Klicks.`,
|
||||
},
|
||||
{
|
||||
id: "routenbild",
|
||||
name: "Routenbild",
|
||||
klicks: 154,
|
||||
tot: true,
|
||||
satz: (p) =>
|
||||
`Auffällig: ${p}\u00a0% klicken auf das Routenbild. Es ist kein Link – hier würde einer passen.`,
|
||||
},
|
||||
{
|
||||
id: "auslastung",
|
||||
name: "Live-Auslastung",
|
||||
klicks: 187,
|
||||
satz: (p) => `Die Live-Auslastung liegt mit ${p}\u00a0% der Klicks auf Platz 2 – sie wird oft angeschaut.`,
|
||||
},
|
||||
{
|
||||
id: "schnupperkurs",
|
||||
name: "Schnupperkurs",
|
||||
klicks: 148,
|
||||
satz: (p) => `Der Schnupperkurs kommt auf ${p}\u00a0% der Klicks, knapp hinter dem Routenbild.`,
|
||||
},
|
||||
{
|
||||
id: "preise",
|
||||
name: "Preise",
|
||||
klicks: 119,
|
||||
satz: (p) => `${p}\u00a0% aller Klicks gehen auf die Karte „Preise“.`,
|
||||
},
|
||||
{
|
||||
id: "oeffnungszeiten",
|
||||
name: "Öffnungszeiten",
|
||||
klicks: 96,
|
||||
satz: (p) => `Die Öffnungszeiten bekommen ${p}\u00a0% der Klicks.`,
|
||||
},
|
||||
];
|
||||
|
||||
export const ZONEN: readonly Zone[] = ROH.map((z) => ({
|
||||
id: z.id,
|
||||
name: z.name,
|
||||
klicks: z.klicks,
|
||||
tot: z.tot === true,
|
||||
satz: z.satz(prozent(z.klicks)),
|
||||
}));
|
||||
|
||||
/** Zone zu einer id (undefined bei unbekannter id). */
|
||||
export const zoneNach = (id: string | null | undefined): Zone | undefined => ZONEN.find((z) => z.id === id);
|
||||
|
||||
/** Anteil einer Zone an allen Klicks in ganzen Prozent. Akzeptiert eine Zone oder ihre id. */
|
||||
export const anteil = (zone: Zone | string): number => {
|
||||
const z = typeof zone === "string" ? zoneNach(zone) : zone;
|
||||
return z ? prozent(z.klicks) : 0;
|
||||
};
|
||||
|
||||
/** Zonen nach Klicks absteigend (bei Gleichstand wie auf der Seite). Optional nur die ersten `anzahl`. */
|
||||
export const rangliste = (anzahl?: number): Zone[] => {
|
||||
const sortiert = [...ZONEN].sort((a, b) => b.klicks - a.klicks);
|
||||
return anzahl === undefined ? sortiert : sortiert.slice(0, anzahl);
|
||||
};
|
||||
|
||||
/** Die meistgeklickte Zone. */
|
||||
export const TOP_ZONE: Zone = rangliste(1)[0];
|
||||
|
||||
/** Text des Schildchens an der gewählten Zone: „Tageskarte kaufen · 402 Klicks · 31 %“. */
|
||||
export const schildText = (zone: Zone): string =>
|
||||
`${zone.name} · ${new Intl.NumberFormat("de-DE").format(zone.klicks)} Klicks · ${prozent(zone.klicks)}\u00a0%${zone.tot ? " · kein Link" : ""}`;
|
||||
|
||||
// ── Ersatz-Rechtecke ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Designgrößen der Flächen (wie FLAECHE in gemeinsam.tsx, hier ohne Import, weil gemeinsam.tsx CSS lädt). */
|
||||
export const FLAECHE_GROESSE: Record<DemoVarianteName, { breite: number; hoehe: number }> = {
|
||||
desktop: { breite: 1512, hoehe: 893 },
|
||||
handy: { breite: 402, hoehe: 874 },
|
||||
};
|
||||
|
||||
/** Rechteck in Designpunkten: x, y, Breite, Höhe. */
|
||||
export type PunktRechteck = { x: number; y: number; b: number; h: number };
|
||||
|
||||
/**
|
||||
* Feste Zonen-Rechtecke je Fassung in Designpunkten. Die Seite misst die echten Rechtecke zur Laufzeit;
|
||||
* diese Werte gelten, wo nicht gemessen werden kann (jsdom), und als Flugziele des simulierten Zeigers.
|
||||
* (Gemessen am gerenderten Layout; ändert sich das Layout, hier nachziehen.)
|
||||
*/
|
||||
export const ZONEN_LAYOUT: Record<DemoVarianteName, Record<string, PunktRechteck>> = {
|
||||
desktop: {
|
||||
logo: { x: 64, y: 29, b: 248, h: 60 },
|
||||
"nav-routen": { x: 453, y: 32, b: 139, h: 54 },
|
||||
"nav-kurse": { x: 600, y: 32, b: 122, h: 54 },
|
||||
telefon: { x: 1176, y: 27, b: 272, h: 64 },
|
||||
tageskarte: { x: 64, y: 481, b: 451, h: 88 },
|
||||
routenbild: { x: 808, y: 144, b: 640, h: 260 },
|
||||
oeffnungszeiten: { x: 808, y: 424, b: 330, h: 178 },
|
||||
auslastung: { x: 64, y: 620, b: 342, h: 248 },
|
||||
schnupperkurs: { x: 430, y: 620, b: 342, h: 248 },
|
||||
preise: { x: 796, y: 620, b: 342, h: 248 },
|
||||
},
|
||||
handy: {
|
||||
logo: { x: 16, y: 68, b: 149, h: 37 },
|
||||
telefon: { x: 245, y: 68, b: 141, h: 38 },
|
||||
"nav-routen": { x: 6, y: 119, b: 79, h: 35 },
|
||||
"nav-kurse": { x: 85, y: 119, b: 67, h: 35 },
|
||||
tageskarte: { x: 20, y: 298, b: 362, h: 58 },
|
||||
auslastung: { x: 20, y: 374, b: 362, h: 78 },
|
||||
routenbild: { x: 20, y: 466, b: 362, h: 150 },
|
||||
schnupperkurs: { x: 20, y: 630, b: 362, h: 66 },
|
||||
preise: { x: 20, y: 710, b: 176, h: 118 },
|
||||
oeffnungszeiten: { x: 206, y: 710, b: 176, h: 118 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Ersatz-Rechtecke einer Fassung, bezogen auf die Fläche (0 bis 1), für punkteInZone aus heat.ts. */
|
||||
export const ersatzRechtecke = (variante: DemoVarianteName): Record<string, ZonenRechteck> => {
|
||||
const { breite, hoehe } = FLAECHE_GROESSE[variante];
|
||||
const aus: Record<string, ZonenRechteck> = {};
|
||||
for (const [id, r] of Object.entries(ZONEN_LAYOUT[variante])) {
|
||||
aus[id] = { x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe };
|
||||
}
|
||||
return aus;
|
||||
};
|
||||
|
||||
/** Mitte einer Zone in Designpunkten, mit einem Versatz (0 bis 1) innerhalb des Rechtecks. */
|
||||
export const zonenPunkt = (variante: DemoVarianteName, id: string, fx = 0.5, fy = 0.5) => {
|
||||
const r = ZONEN_LAYOUT[variante][id];
|
||||
return { x: Math.round(r.x + r.b * fx), y: Math.round(r.y + r.h * fy) };
|
||||
};
|
||||
Reference in new issue
Block a user