Fadenkreuz- und Koordinaten-Cursor
Lageplan eines Kulturhauses: Ein feines Fadenkreuz mit Meter-Koordinaten fährt über den Grundriss und nimmt die Farbe des Saals an, über dem es steht.
Zwei feine Linien kreuzen sich am Zeiger, eine waagerecht, eine senkrecht, über die ganze Fläche. Neben dem Kreuzungspunkt laufen Koordinaten mit, meist in Monospace mit Tabellenziffern. Je nach Variante folgen die Linien direkt oder mit leichtem Nachlauf, rasten an Rasterlinien ein oder reagieren auf das, was darunter liegt.
Technisch und präzise, wie eine Bauzeichnung. Passt zum Raster- und Swiss-Look und zu Tech-Marken. Auf Plänen und Karten ist es zusätzlich nützlich: Die Koordinate sagt, wo man gerade ist.
Wann einsetzen
Architektur, Ingenieurwesen, Raumfahrt und Tech, Raster-Portfolios, Grundrisse, Lagepläne und Kartenansichten.
Wann nicht
Warme, emotionale Marken. Textreiche Seiten, weil die Linien beim Lesen stören. Als Cursor für die ganze Seite bei öffentlichen Häusern mit breitem Publikum; dort nur auf einen Plan oder eine Karte begrenzen.
Varianten
- Fadenkreuz über die volle Fläche, optional mit Verzerrung per SVG-Filter über Links und Bildern
- Nur ein Koordinaten-Etikett am Zeiger, ohne Linien
- Rastendes Fadenkreuz, das an Rasterlinien einschnappt
Umsetzung
Zwei fixe 1-px-Elemente mit pointer-events: none, per transform an die Mausposition gesetzt, nie über left/top. Nachlauf per rAF-Lerp (etwa 0,2 pro Bild), auf die Bildrate umgerechnet. Koordinaten mit font-variant-numeric: tabular-nums, damit nichts springt. Rasten: Zielwert vor dem Lerp auf das Raster runden. Verzerrung: SVG-Filter feTurbulence mit feDisplacementMap auf den Linien, scale bei Hover animieren. Auf Plänen die Position über getScreenCTM() in Planeinheiten umrechnen.
Robust umsetzen
Telefon. Ohne Hover entfällt das Folgen. Sinnvoll ist ein festes Kreuz, das Tippen an eine Stelle setzt.
Ohne Bewegung. Linien ohne Verzerrung und ohne Lerp, direkt am Zeiger.
Barrierefrei. Kreuz und Etikett aria-hidden. Linien kontrastarm halten, damit Inhalte lesbar bleiben. System-Cursor sichtbar lassen. Die Information des Etiketts muss auch ohne Maus erreichbar sein, etwa über eine Liste mit Fokus.
Code
<!DOCTYPE html>
<!--
Fadenkreuz- und Koordinaten-Cursor
Fundus · Cursor · https://cecodesign.de/lab/fundus/cursor/crosshair-coordinates-cursor/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fadenkreuz- und Koordinaten-Cursor · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
:root {
--grund: #efede9; --tinte: #0b0b0b; --leise: #5f5d58; --linie: rgba(11, 11, 11, .16); --hauch: rgba(11, 11, 11, .06);
--musik: #c4421a; --tanz: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;
--sparte: var(--tinte);
--kurve: cubic-bezier(0.215, 0.61, 0.355, 1); /* easeOutCubic, Rezept kultur-programm-haltung */
--ui: 150ms;
--kopf: 64px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body { min-height: 100svh; display: flex; flex-direction: column; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; }
.mono { font: 500 .8125rem/1.3 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .04em; font-variant-numeric: tabular-nums; }
.versal { text-transform: uppercase; letter-spacing: .1em; font-size: .6875rem; }
/* Kopf: sticky, klare Unterkante */
.kopf { position: sticky; top: 0; z-index: 20; height: var(--kopf); display: flex; align-items: center; gap: 28px; padding: 0 clamp(16px, 3vw, 40px); background: var(--grund); border-bottom: 1px solid var(--tinte); }
.marke { font: 850 1.375rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; text-decoration: none; display: flex; align-items: baseline; gap: .35em; }
.marke small { font: 500 .6875rem/1 'IBM Plex Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); }
.navi { display: flex; gap: 22px; margin-left: auto; font: 600 .9375rem/1 'Schibsted Grotesk', sans-serif; }
.navi a { text-decoration: none; padding: 6px 0; border-bottom: 2px solid transparent; transition: border-color var(--ui) var(--kurve); }
.navi a:hover, .navi a[aria-current] { border-color: var(--tinte); }
.ticket { font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; background: var(--tinte); color: var(--grund); padding: 11px 16px; border: 1px solid var(--tinte); text-decoration: none; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.ticket:hover { background: var(--grund); color: var(--tinte); }
.menue { display: none; }
/* Bühne: links Programm-Spalte, rechts der Plan */
.buehne { flex: 1; display: grid; grid-template-columns: minmax(300px, 380px) 1fr; border-bottom: 1px solid var(--linie); }
.spalte { padding: clamp(22px, 3vw, 40px) clamp(16px, 3vw, 40px); border-right: 1px solid var(--linie); display: flex; flex-direction: column; gap: 26px; }
.spalte .ort { color: var(--leise); }
h1 { font: 850 clamp(2.5rem, 4.4vw, 4.25rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.025em; margin-top: 10px; text-wrap: balance; }
.karte { border-top: 1px solid var(--tinte); padding-top: 14px; min-height: 168px; }
.karte .sparte { display: flex; align-items: center; gap: 8px; }
.karte .sparte i { width: 12px; height: 12px; background: var(--sparte); transition: background-color var(--ui) var(--kurve); }
.karte h2 { font: 800 clamp(1.375rem, 2.2vw, 2rem)/1.05 'Schibsted Grotesk', sans-serif; letter-spacing: -.015em; margin: 12px 0 8px; }
.karte .titel { font-size: 1.125rem; line-height: 1.45; max-width: 34ch; }
.karte .zeit { margin-top: 10px; color: var(--leise); }
.karte .inhalt { transition: opacity 200ms var(--kurve), translate 200ms var(--kurve); }
.karte.wechselt .inhalt { opacity: 0; translate: 0 6px; transition-duration: 0ms; }
.saele { list-style: none; border-top: 1px solid var(--linie); }
.saele button { width: 100%; display: grid; grid-template-columns: 14px 1fr auto; align-items: center; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--linie); text-align: left; font: 600 1.0625rem/1.2 'Schibsted Grotesk', sans-serif; transition: padding var(--ui) var(--kurve); }
.saele button i { width: 10px; height: 10px; }
.saele button .mono { color: var(--leise); font-weight: 400; }
.saele button:hover, .saele button[aria-current="true"] { padding-left: 8px; }
.saele button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.hinweis { font-size: 1rem; line-height: 1.5; color: var(--leise); max-width: 40ch; margin-top: auto; }
.hinweis b { color: var(--tinte); font-weight: 700; }
/* Plan */
.planfeld { position: relative; display: flex; flex-direction: column; padding: clamp(16px, 2.4vw, 32px) clamp(16px, 3vw, 40px) 18px; min-width: 0; }
.planfeld .kopfzeile { display: flex; justify-content: space-between; gap: 16px; color: var(--leise); padding-bottom: 12px; }
.plan { flex: 1; display: grid; place-items: center; min-height: 0; }
.zeichnung { position: relative; width: min(100%, calc((100svh - var(--kopf) - 150px) * 1.4375)); }
.plan svg { display: block; width: 100%; height: auto; overflow: visible; cursor: crosshair; touch-action: manipulation; }
.plan .wand { fill: none; stroke: var(--tinte); stroke-width: 4; stroke-linecap: square; }
.plan .innen { fill: none; stroke: var(--tinte); stroke-width: 2.5; stroke-linecap: square; }
.plan .duenn { fill: none; stroke: var(--tinte); stroke-width: .8; }
.plan .raster { stroke: var(--tinte); stroke-width: .35; opacity: .14; }
.plan .achse { stroke: var(--tinte); stroke-width: .5; stroke-dasharray: 6 3 1 3; opacity: .35; }
.plan .blase circle { fill: var(--grund); stroke: var(--tinte); stroke-width: .8; }
.plan text { font-family: 'IBM Plex Mono', monospace; font-weight: 500; fill: var(--tinte); }
.plan .blase text { font-size: 7.5px; text-anchor: middle; dominant-baseline: central; }
.plan .raumname { font: 800 12px 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; }
.plan .raumsparte { font-size: 6.5px; letter-spacing: .1em; fill: var(--leise); }
.plan .klein { font-size: 6px; letter-spacing: .08em; fill: var(--leise); }
.plan .flaeche { opacity: .1; transition: opacity var(--ui) var(--kurve); }
.plan .flaeche.an { opacity: .26; }
.plan .moebel { fill: var(--tinte); opacity: .82; }
.plan .reihe { stroke: var(--tinte); stroke-width: 2.2; stroke-dasharray: 4.2 1.6; opacity: .5; }
/* Fadenkreuz: zwei 1-px-Linien, per transform gesetzt, pointer-events: none */
.kreuz { position: absolute; inset: 0; pointer-events: none; overflow: hidden; opacity: 0; transition: opacity var(--ui) var(--kurve); }
.kreuz.an { opacity: 1; }
.kreuz .h, .kreuz .v { position: absolute; left: 0; top: 0; background: var(--sparte); opacity: .72; transition: background-color var(--ui) var(--kurve); will-change: transform; }
.kreuz .h { width: 100%; height: 1px; }
.kreuz .v { width: 1px; height: 100%; }
.kreuz .punkt { position: absolute; left: 0; top: 0; width: 9px; height: 9px; margin: -4px 0 0 -4px; border: 1px solid var(--sparte); background: var(--grund); transition: border-color var(--ui) var(--kurve); }
.kreuz .lineal { position: absolute; left: 0; top: 0; padding: 3px 5px; background: var(--tinte); color: var(--grund); font: 500 10.5px/1 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; white-space: nowrap; }
.kreuz .etikett { position: absolute; left: 0; top: 0; padding: 7px 9px 8px; background: var(--grund); border: 1px solid var(--tinte); font: 500 11px/1.35 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; white-space: nowrap; box-shadow: 3px 3px 0 var(--sparte); transition: box-shadow var(--ui) var(--kurve); }
.kreuz .etikett b { display: block; font: 800 12.5px/1.2 'Schibsted Grotesk', sans-serif; letter-spacing: -.01em; margin-top: 3px; }
.kreuz .etikett s { text-decoration: none; color: var(--leise); }
[data-variante="koordinaten"] .kreuz .h, [data-variante="koordinaten"] .kreuz .v, [data-variante="koordinaten"] .kreuz .lineal { display: none; }
[data-variante="rastend"] .plan .raster { opacity: .3; }
/* Demo-Leiste (im Fluss, nicht fixiert) */
.leiste { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 18px; padding-top: 14px; color: var(--leise); }
.leiste .wahl { display: flex; border: 1px solid var(--linie); }
.leiste .wahl button { padding: 7px 11px; font: 500 .6875rem/1 'IBM Plex Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.leiste .wahl button + button { border-left: 1px solid var(--linie); }
.leiste .wahl button[aria-pressed="true"] { background: var(--tinte); color: var(--grund); }
.leiste .wahl button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 1px; }
.leiste .werte { font-size: .6875rem; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .leiste, .vorschau .hinweis, .vorschau .navi { display: none; }
.vorschau .buehne { grid-template-columns: 300px 1fr; }
.vorschau .ticket { margin-left: auto; }
.vorschau .zeichnung { width: 100%; }
@media (max-width: 1100px) { .navi a:nth-child(n+3) { display: none; } }
@media (max-width: 860px) {
.buehne { display: flex; flex-direction: column; }
.spalte { display: contents; }
.spalte > * { margin-inline: clamp(16px, 3vw, 40px); }
.spalte > div:first-child { order: 0; padding-top: 22px; }
.planfeld { order: 1; padding-top: 14px; }
.karte { order: 2; min-height: 0; }
.saele { order: 3; margin-top: 18px; }
.hinweis { order: 4; margin: 18px clamp(16px, 3vw, 40px) 28px; }
.zeichnung { width: 100%; }
}
@media (max-width: 640px) {
:root { --kopf: 56px; }
.kopf { gap: 12px; }
.navi { display: none; }
.marke small { display: none; }
.ticket { margin-left: auto; padding: 10px 13px; }
.menue { display: block; font: 700 .9375rem/1 'Schibsted Grotesk', sans-serif; padding: 10px 0 10px 4px; }
h1 { font-size: 2.5rem; }
.planfeld .kopfzeile span:last-child { display: none; }
.kreuz .etikett { font-size: 10px; padding: 5px 7px 6px; }
.kreuz .etikett b { font-size: 11px; }
}
/* Touch: kein Folgen; Tippen setzt das Fadenkreuz fest an die Stelle */
@media (hover: none) {
.plan svg { cursor: auto; }
.saele button:hover { padding-left: 0; }
.saele button[aria-current="true"] { padding-left: 8px; }
}
/* Reduzierte Bewegung: ohne Lerp, ohne Übergänge */
.reduziert .kreuz, .reduziert .kreuz *, .reduziert .karte .inhalt, .reduziert .plan .flaeche, .reduziert .saele button { transition: none !important; }
</style>
</head>
<body data-variante="fadenkreuz">
<header class="kopf">
<a class="marke" href="#start">Modulhaus <small>Ulm</small></a>
<nav class="navi" aria-label="Hauptnavigation">
<a href="#programm">Programm</a><a href="#ausstellungen">Ausstellungen</a><a href="#besuch" aria-current="page">Besuch</a><a href="#vorlesen">Vorlesen</a>
</nav>
<a class="ticket" href="#tickets">Tickets</a>
<button class="menue" type="button" aria-label="Menü öffnen">Menü</button>
</header>
<main class="buehne" id="start">
<section class="spalte" aria-labelledby="titel">
<div>
<p class="mono versal ort">Besuch · Lageplan Erdgeschoss</p>
<h1 id="titel">Vier Säle, ein Foyer.</h1>
</div>
<div class="karte" data-karte aria-live="polite">
<div class="inhalt">
<p class="mono versal sparte"><i aria-hidden="true"></i><span data-k-sparte>Foyer</span></p>
<h2 data-k-raum>Foyer und Kasse</h2>
<p class="titel" data-k-titel>Fahren Sie über den Plan oder wählen Sie einen Saal.</p>
<p class="mono zeit" data-k-zeit>Kasse ab 9:30 · Café bis 22 Uhr</p>
</div>
</div>
<ul class="saele" aria-label="Säle im Erdgeschoss" data-saele></ul>
<p class="hinweis"><b>Stufenlos.</b> Alle Säle liegen ebenerdig. Aufzug zur Garderobe im Untergeschoss, induktive Höranlage in Saal 3.</p>
</section>
<section class="planfeld" aria-label="Lageplan">
<div class="kopfzeile mono versal"><span>Ebene 0 · M 1:200</span><span>Achsen A–G / 1–6 · Maße in m</span></div>
<div class="plan" data-plan>
<div class="zeichnung" data-zeichnung>
<svg viewBox="-36 -40 552 384" role="img" aria-label="Grundriss des Erdgeschosses: oben Halle 1, Kino 2 und Saal 3, unten links das Foyer mit Eingang, unten rechts Studio 4." data-svg>
<defs>
<pattern id="schraffur" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><line x1="0" y1="0" x2="0" y2="5" stroke="#0b0b0b" stroke-width=".6" opacity=".45"/></pattern>
<pattern id="meter" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M10 0H0V10" fill="none" class="raster"/></pattern>
</defs>
<!-- Raumflächen in Spartenfarbe -->
<g data-flaechen>
<rect class="flaeche" data-raum="halle" x="0" y="0" width="220" height="160" fill="#2f7d4a"/>
<rect class="flaeche" data-raum="kino" x="220" y="0" width="120" height="160" fill="#b0186b"/>
<rect class="flaeche" data-raum="saal" x="340" y="0" width="140" height="160" fill="#c4421a"/>
<rect class="flaeche" data-raum="studio" x="300" y="200" width="180" height="100" fill="#3d5f9a"/>
<path class="flaeche" data-raum="foyer" d="M0 160H480V200H300V300H0Z" fill="#0b0b0b" style="opacity:.035"/>
</g>
<!-- 1-m-Raster -->
<rect x="0" y="0" width="480" height="300" fill="url(#meter)"/>
<!-- Achsen -->
<g>
<line class="achse" x1="0" y1="-14" x2="0" y2="300"/><line class="achse" x1="80" y1="-14" x2="80" y2="300"/><line class="achse" x1="160" y1="-14" x2="160" y2="300"/><line class="achse" x1="240" y1="-14" x2="240" y2="300"/><line class="achse" x1="320" y1="-14" x2="320" y2="300"/><line class="achse" x1="400" y1="-14" x2="400" y2="300"/><line class="achse" x1="480" y1="-14" x2="480" y2="300"/>
<line class="achse" x1="-14" y1="0" x2="480" y2="0"/><line class="achse" x1="-14" y1="60" x2="480" y2="60"/><line class="achse" x1="-14" y1="120" x2="480" y2="120"/><line class="achse" x1="-14" y1="180" x2="480" y2="180"/><line class="achse" x1="-14" y1="240" x2="480" y2="240"/><line class="achse" x1="-14" y1="300" x2="480" y2="300"/>
</g>
<g class="blase">
<g transform="translate(0 -22)"><circle r="7.5"/><text>A</text></g><g transform="translate(80 -22)"><circle r="7.5"/><text>B</text></g><g transform="translate(160 -22)"><circle r="7.5"/><text>C</text></g><g transform="translate(240 -22)"><circle r="7.5"/><text>D</text></g><g transform="translate(320 -22)"><circle r="7.5"/><text>E</text></g><g transform="translate(400 -22)"><circle r="7.5"/><text>F</text></g><g transform="translate(480 -22)"><circle r="7.5"/><text>G</text></g>
<g transform="translate(-22 0)"><circle r="7.5"/><text>1</text></g><g transform="translate(-22 60)"><circle r="7.5"/><text>2</text></g><g transform="translate(-22 120)"><circle r="7.5"/><text>3</text></g><g transform="translate(-22 180)"><circle r="7.5"/><text>4</text></g><g transform="translate(-22 240)"><circle r="7.5"/><text>5</text></g><g transform="translate(-22 300)"><circle r="7.5"/><text>6</text></g>
</g>
<!-- Einbauten -->
<g class="moebel">
<rect x="77" y="57" width="6" height="6"/><rect x="157" y="57" width="6" height="6"/><rect x="77" y="117" width="6" height="6"/><rect x="157" y="117" width="6" height="6"/>
<rect x="77" y="237" width="6" height="6"/><rect x="157" y="237" width="6" height="6"/><rect x="237" y="237" width="6" height="6"/>
<!-- Halle 1: Sockel und Stellwand -->
<rect x="36" y="34" width="22" height="22" opacity=".35"/><rect x="104" y="26" width="44" height="6"/><rect x="186" y="34" width="14" height="14" opacity=".35"/>
<rect x="30" y="92" width="30" height="10" opacity=".35"/><rect x="112" y="80" width="4" height="56"/><rect x="176" y="96" width="22" height="22" opacity=".35"/>
<!-- Kino 2: Leinwand; Saal 3: Bühne -->
<rect x="234" y="12" width="92" height="3"/>
</g>
<rect x="352" y="10" width="116" height="34" fill="url(#schraffur)" class="duenn"/>
<g>
<line class="reihe" x1="236" y1="44" x2="324" y2="44"/><line class="reihe" x1="236" y1="56" x2="324" y2="56"/><line class="reihe" x1="236" y1="68" x2="324" y2="68"/><line class="reihe" x1="236" y1="80" x2="324" y2="80"/><line class="reihe" x1="236" y1="92" x2="324" y2="92"/><line class="reihe" x1="236" y1="104" x2="324" y2="104"/><line class="reihe" x1="236" y1="116" x2="324" y2="116"/><line class="reihe" x1="236" y1="128" x2="324" y2="128"/>
<line class="reihe" x1="356" y1="62" x2="464" y2="62"/><line class="reihe" x1="356" y1="73" x2="464" y2="73"/><line class="reihe" x1="356" y1="84" x2="464" y2="84"/><line class="reihe" x1="356" y1="95" x2="464" y2="95"/><line class="reihe" x1="356" y1="106" x2="464" y2="106"/><line class="reihe" x1="356" y1="117" x2="464" y2="117"/><line class="reihe" x1="356" y1="128" x2="464" y2="128"/>
</g>
<!-- Studio 4: Tanzboden; Foyer: Kasse, Garderobe, Treppe, Aufzug, Café -->
<rect x="322" y="214" width="136" height="56" class="duenn" stroke-dasharray="3 2"/>
<rect x="22" y="186" width="44" height="10" class="moebel"/>
<line x1="6" y1="214" x2="6" y2="284" class="duenn" stroke-width="2.4"/>
<g class="duenn"><rect x="196" y="176" width="52" height="22"/><path d="M203 176v22M210 176v22M217 176v22M224 176v22M231 176v22M238 176v22"/><rect x="258" y="176" width="22" height="22"/><path d="M258 176l22 22M280 176l-22 22"/></g>
<g class="duenn"><circle cx="168" cy="252" r="6"/><circle cx="194" cy="270" r="6"/><circle cx="222" cy="250" r="6"/><circle cx="250" cy="272" r="6"/><rect x="230" y="286" width="56" height="8"/></g>
<rect x="96" y="210" width="34" height="22" class="duenn"/>
<!-- Wände mit Türöffnungen -->
<path class="wand" d="M100 300H0V0H480V300H140"/>
<path class="innen" d="M0 160H80M130 160H260M290 160H400M440 160H480M220 0V160M340 0V160M300 300V200H340M380 200H480"/>
<!-- Beschriftung -->
<text class="raumname" x="10" y="20">Halle 1</text><text class="raumsparte" x="10" y="30">KUNST · 352 m²</text>
<text class="raumname" x="230" y="150">Kino 2</text><text class="raumsparte" x="276" y="150">FILM</text>
<text class="raumname" x="350" y="150">Saal 3</text><text class="raumsparte" x="394" y="150">MUSIK</text>
<text class="raumname" x="310" y="296" dy="-6">Studio 4</text><text class="raumsparte" x="366" y="290">TANZ · BÜHNE</text>
<text class="raumname" x="10" y="292" dy="-2">Foyer</text>
<text class="klein" x="24" y="206">KASSE</text><text class="klein" x="12" y="250">GARDEROBE ↓UG</text><text class="klein" x="98" y="242">WC · BF</text><text class="klein" x="198" y="210">TREPPE</text><text class="klein" x="256" y="210">AUFZUG</text><text class="klein" x="168" y="236">CAFÉ</text>
<!-- Eingang, Maßstab, Nordpfeil -->
<path d="M120 334V312M114 318l6-6 6 6" class="duenn" stroke-width="1.2"/><text class="klein" x="132" y="330">EINGANG</text>
<g transform="translate(330 322)"><rect width="50" height="4" fill="#0b0b0b"/><rect x="50" width="50" height="4" fill="none" stroke="#0b0b0b" stroke-width=".8"/><text class="klein" y="16">0</text><text class="klein" x="47" y="16">5</text><text class="klein" x="92" y="16">10 M</text></g>
<g transform="translate(466 328)"><circle r="9" class="duenn"/><path d="M0 -12 3.6 4 0 1.2 -3.6 4Z" fill="#0b0b0b"/><text class="klein" x="13" y="2.5">N</text></g>
</svg>
<div class="kreuz" data-kreuz aria-hidden="true">
<div class="h" data-h></div><div class="v" data-v></div>
<div class="punkt" data-punkt></div>
<div class="lineal" data-lx>00,00</div><div class="lineal" data-ly>00,00</div>
<div class="etikett" data-etikett><span data-xy>X 00,00 <s>·</s> Y 00,00</span><b data-ort>Foyer</b></div>
</div>
</div>
</div>
<div class="leiste">
<div class="wahl" role="group" aria-label="Variante des Cursors">
<button type="button" data-wahl="fadenkreuz" aria-pressed="true">Fadenkreuz</button><button type="button" data-wahl="koordinaten" aria-pressed="false">Nur Koordinaten</button><button type="button" data-wahl="rastend" aria-pressed="false">Rastend</button>
</div>
<span class="mono werte" data-werte>Lerp 0,2 pro Bild · Linie 1 px · Easing UI 150 ms easeOutCubic</span>
</div>
</section>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#0b0b0b" stroke="#efede9" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement, body = document.body;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const svg = $('[data-svg]'), plan = $('[data-plan]'), zeich = $('[data-zeichnung]'), kreuz = $('[data-kreuz]');
const H = $('[data-h]'), V = $('[data-v]'), P = $('[data-punkt]'), LX = $('[data-lx]'), LY = $('[data-ly]');
const ET = $('[data-etikett]'), XY = $('[data-xy]'), ORT = $('[data-ort]'), karte = $('[data-karte]');
const zeiger = $('[data-zeiger]');
// Räume in Metern (Ursprung Achse A/1), Programm von heute
const RAEUME = [
{ id: 'halle', name: 'Halle 1', sparte: 'Kunst', farbe: '#2f7d4a', r: [0, 0, 22, 16], titel: 'Maß halten. Raster, Module und Systeme im Bauen seit 1950', zeit: 'Ausstellung bis 14.02.2027 · Di–So 10–18 Uhr', kurz: 'bis 14.02.' },
{ id: 'kino', name: 'Kino 2', sparte: 'Film', farbe: '#b0186b', r: [22, 0, 12, 16], titel: 'Die vermessene Stadt. Dokumentarfilm, 94 Min., OmU', zeit: 'Heute 20:15 · 96 Plätze · 8 €', kurz: '20:15' },
{ id: 'saal', name: 'Saal 3', sparte: 'Musik', farbe: '#c4421a', r: [34, 0, 14, 16], titel: 'Quartett Blau spielt Reich, Pärt und Glass', zeit: 'Heute 19:30 · 240 Plätze · ab 18 €', kurz: '19:30' },
{ id: 'studio', name: 'Studio 4', sparte: 'Tanz', farbe: '#3d5f9a', r: [30, 20, 18, 10], titel: 'Kompanie Safranberg: Schichten. Uraufführung', zeit: 'Fr 07.11. · 20:00 · 120 Plätze', kurz: 'Fr 20:00' }
];
const FOYER = { id: 'foyer', name: 'Foyer und Kasse', kurzname: 'Foyer', sparte: 'Foyer', farbe: '#0b0b0b', titel: 'Kasse, Café, Garderobe. Treffpunkt für Führungen jeden Sonntag um 11 Uhr.', zeit: 'Kasse ab 9:30 · Café bis 22 Uhr' };
const touch = matchMedia('(hover: none)').matches;
const START = { titel: touch ? 'Tippen Sie auf den Plan oder wählen Sie einen Saal.' : 'Fahren Sie über den Plan oder wählen Sie einen Saal.' };
const B = 48, T = 30; // Gebäude 48 × 30 m
const LERP = 0.2, RASTER = 2; // Lerp 0,2 pro Bild bei 60 Hz; Raster 2 m
const WERTE = {
fadenkreuz: 'Lerp 0,2 pro Bild · Linie 1 px · Easing UI 150 ms easeOutCubic',
koordinaten: 'Ohne Verzögerung · Etikett 14 px versetzt · tabular-nums',
rastend: 'Raster 2 m · Lerp 0,2 pro Bild · Linie 1 px'
};
// Liste der Säle aufbauen
const liste = $('[data-saele]');
RAEUME.forEach(r => {
const li = document.createElement('li');
li.innerHTML = `<button type="button" data-zu="${r.id}" aria-current="false"><i style="background:${r.farbe}" aria-hidden="true"></i><span>${r.name} <span class="mono">· ${r.sparte}</span></span><span class="mono">${r.kurz}</span></button>`;
liste.appendChild(li);
});
const knoepfe = [...liste.querySelectorAll('button')];
let variante = 'fadenkreuz';
const ziel = { x: 24, y: 15 }, ist = { x: 24, y: 15 };
let an = false, erster = true, raumId = null, letzte = performance.now(), laeuft = false;
const raumBei = (x, y) => RAEUME.find(r => x >= r.r[0] && x <= r.r[0] + r.r[2] && y >= r.r[1] && y <= r.r[1] + r.r[3]) || FOYER;
const zahl = v => (v < 10 ? '0' : '') + v.toFixed(2).replace('.', ',');
const achse = (x, y) => 'ABCDEFG'[Math.min(6, Math.round(x / 8))] + '/' + (Math.min(5, Math.round(y / 6)) + 1);
// Geometrie: Meter → Pixel im Planfeld (über die Bildschirm-Matrix des SVG)
let g = { ox: 0, oy: 0, s: 1 };
function messen() {
const m = svg.getScreenCTM(), p = zeich.getBoundingClientRect();
if (!m) return;
g = { ox: m.e - p.left, oy: m.f - p.top, s: m.a * 10 };
}
function ausPunkt(cx, cy) {
const p = zeich.getBoundingClientRect();
return { x: (cx - p.left - g.ox) / g.s, y: (cy - p.top - g.oy) / g.s };
}
function setzeZiel(x, y, sofort) {
if (variante === 'rastend') { x = Math.round(x / RASTER) * RASTER; y = Math.round(y / RASTER) * RASTER; }
ziel.x = Math.max(0, Math.min(B, x)); ziel.y = Math.max(0, Math.min(T, y));
if (erster || sofort || reduziert()) { ist.x = ziel.x; ist.y = ziel.y; erster = false; }
zeigen(true);
}
function zeigen(v) { an = v; kreuz.classList.toggle('an', v); if (!v) { erster = true; waehle(null); } starten(); }
// Raumwechsel: Fläche, Linienfarbe, Karte, Liste
function waehle(r) {
const id = r ? r.id : null;
if (id === raumId) return;
raumId = id;
document.querySelectorAll('[data-flaechen] .flaeche').forEach(f => f.classList.toggle('an', f.dataset.raum === id && id !== 'foyer'));
knoepfe.forEach(k => k.setAttribute('aria-current', String(k.dataset.zu === id)));
const farbe = r ? r.farbe : '#0b0b0b';
html.style.setProperty('--sparte', farbe);
ORT.textContent = r ? (r.kurzname || r.name) + ' · ' + r.sparte : '';
if (r && r.id === 'foyer') ORT.textContent = 'Foyer';
const d = r || Object.assign({}, FOYER, START);
karte.classList.add('wechselt');
karte.querySelector('[data-k-sparte]').textContent = r && r.id !== 'foyer' ? 'Sparte ' + d.sparte : 'Foyer';
karte.querySelector('[data-k-raum]').textContent = d.name;
karte.querySelector('[data-k-titel]').textContent = d.titel;
karte.querySelector('[data-k-zeit]').textContent = d.zeit;
requestAnimationFrame(() => requestAnimationFrame(() => karte.classList.remove('wechselt')));
}
function malen() {
const px = g.ox + ist.x * g.s, py = g.oy + ist.y * g.s;
H.style.transform = `translate3d(0, ${py}px, 0)`;
V.style.transform = `translate3d(${px}px, 0, 0)`;
P.style.transform = `translate3d(${px}px, ${py}px, 0)`;
LX.textContent = zahl(ist.x); LY.textContent = zahl(ist.y);
LX.style.transform = `translate3d(${px - LX.offsetWidth / 2}px, 0, 0)`;
LY.style.transform = `translate3d(0, ${py - LY.offsetHeight / 2}px, 0)`;
XY.innerHTML = `X ${zahl(ist.x)} <s>·</s> Y ${zahl(ist.y)} <s>· ${achse(ist.x, ist.y)}</s>`;
// Etikett unten rechts vom Kreuzungspunkt, an den Rändern auf die andere Seite
const w = ET.offsetWidth, h = ET.offsetHeight, pw = zeich.clientWidth, ph = zeich.clientHeight;
const ex = px + 14 + w > pw - 4 ? px - 14 - w : px + 14;
const ey = py + 14 + h > ph - 4 ? py - 14 - h : py + 14;
ET.style.transform = `translate3d(${ex}px, ${ey}px, 0)`;
if (an) waehle(raumBei(ist.x, ist.y));
}
function bild(t) {
const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
const k = variante === 'koordinaten' || reduziert() ? 1 : 1 - Math.pow(1 - LERP, dt);
ist.x += (ziel.x - ist.x) * k; ist.y += (ziel.y - ist.y) * k;
malen();
const ruhig = Math.abs(ziel.x - ist.x) < 0.002 && Math.abs(ziel.y - ist.y) < 0.002;
if (ruhig) { laeuft = false; return; }
requestAnimationFrame(bild);
}
function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
// Maus: folgen; Touch: Tippen setzt fest
svg.addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' || vorf) return;
const m = ausPunkt(e.clientX, e.clientY);
if (m.x < 0 || m.x > B || m.y < 0 || m.y > T) { if (an) zeigen(false); return; }
setzeZiel(m.x, m.y);
});
plan.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !vorf) zeigen(false); });
svg.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse') return;
stoppVorf();
const m = ausPunkt(e.clientX, e.clientY);
if (m.x < 0 || m.x > B || m.y < 0 || m.y > T) { zeigen(false); return; }
setzeZiel(m.x, m.y, true);
});
// Liste: Hover und Tastatur fahren das Kreuz in die Saalmitte
const mitte = r => setzeZiel(r.r[0] + r.r[2] / 2, r.r[1] + r.r[3] / 2);
knoepfe.forEach(k => {
const r = RAEUME.find(x => x.id === k.dataset.zu);
k.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && !vorf) mitte(r); });
k.addEventListener('focus', () => { if (!vorf) mitte(r); });
k.addEventListener('click', () => { stoppVorf(); mitte(r); });
});
liste.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !vorf && !liste.contains(document.activeElement)) zeigen(false); });
// Varianten
const wahl = [...document.querySelectorAll('[data-wahl]')];
function setzeVariante(v) {
variante = v; body.dataset.variante = v;
wahl.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.wahl === v)));
$('[data-werte]').textContent = WERTE[v];
if (an) setzeZiel(ziel.x, ziel.y);
}
wahl.forEach(b => b.addEventListener('click', () => { stoppVorf(); setzeVariante(b.dataset.wahl); }));
addEventListener('resize', () => { messen(); malen(); });
document.fonts && document.fonts.ready.then(() => { messen(); malen(); });
messen();
// Touch: kein Folgen. Zum Start steht das Kreuz fest auf der nächsten Veranstaltung (Saal 3, 19:30).
if (touch) { $('[data-k-titel]').textContent = START.titel; setzeZiel(41, 8, true); }
// Vorführung: gedachter Zeiger fährt eine Schleife durch alle Säle
let uhr = null, vorf = false, t0 = 0, runde = -1, stand = 0;
const FOLGE = ['fadenkreuz', 'rastend', 'koordinaten'], RUNDE = 6500;
const STATIONEN = [[11, 8], [28, 9], [41, 9], [39, 25], [14, 24]];
function vorfuehren(ab) {
pause();
vorf = true; html.classList.add('vorfuehrung');
t0 = performance.now() - (ab || 0); runde = -1;
if (reduziert()) { // ohne Bewegung: Sprung von Saal zu Saal
let i = 0;
const sprung = () => { const s = STATIONEN[i % STATIONEN.length]; zeigerAuf(s[0], s[1]); setzeZiel(s[0], s[1], true); i++; uhr = setTimeout(sprung, 1400); };
zeiger.style.display = 'none'; sprung(); return;
}
zeiger.style.display = '';
const schritt = t => {
const z = Math.max(0, t - t0), r = Math.floor(z / RUNDE);
if (r !== runde) { runde = r; setzeVariante(FOLGE[r % FOLGE.length]); }
const q = z / 1000;
const x = 24 + 20.5 * Math.sin(q * 0.92), y = 15 + 11.5 * Math.sin(q * 1.37 + 0.6);
zeigerAuf(x, y);
setzeZiel(x, y);
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function zeigerAuf(x, y) {
const p = zeich.getBoundingClientRect();
zeiger.style.transform = `translate(${p.left + g.ox + x * g.s - 2}px, ${p.top + g.oy + y * g.s - 1}px)`;
}
function pause() { if (uhr) stand = performance.now() - t0; cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; }
function stoppVorf() { if (!vorf) return; pause(); vorf = false; html.classList.remove('vorfuehrung'); }
function neustart() { stoppVorf(); setzeVariante('fadenkreuz'); zeigen(false); messen(); }
function weiter() { if (!uhr && vorf) vorfuehren(stand); }
})();
</script>
</body>
</html>