Linien- und Punktraster
Ein feines Punktraster unter der Fahrschul-Route wölbt sich um den Zeiger; dazu die reine CSS-Fassung und ein Linienraster mit Plus-Marken.
Ein feines Raster aus Punkten oder Linien liegt hinter dem Inhalt. Punktraster: ein radial-gradient mit 1 px Punkt und weicher Kante, wiederholt alle 16 bis 24 px. Linienraster: zwei linear-gradients (90deg und 0deg) mit 1 px Linie alle 32 bis 64 px. Eine radiale Maske blendet das Raster zum Rand hin aus. Als Steigerung reagieren die Punkte auf den Cursor (Canvas oder Shader: Punkte in der Nähe werden größer, heller oder weichen aus), oder die Spaltenlinien des Layouts werden sichtbar, mit Plus-Marken an den Schnittpunkten.
Das Raster steht für Präzision, Planung und Blaupause. Es gibt leeren Flächen Struktur, ohne mit dem Inhalt zu konkurrieren. Liegen Inhalte sichtbar auf den Rasterpunkten, wirkt das Layout gebaut statt zufällig.
Wann einsetzen
Tech-, Dev-Tool-, Architektur- und Studioseiten. Hintergründe von Feature-Sektionen. Swiss- und Editorial-Layouts mit sichtbaren Spalten. Überall, wo ein Plan, ein Ablauf oder eine Karte gezeigt wird.
Wann nicht
Wenn das Raster nur Leere füllt und nichts darauf Bezug nimmt. In Kombination mit Aurora-Glow (Klischee von 2025). Hinter langen Fließtexten.
Varianten
- Statisches Punktraster mit radialem Ausblenden (reines CSS)
- Interaktives Punktraster mit Cursor-Wölbung (Canvas)
- Linienraster oder sichtbare Spaltenlinien mit Plus-Marken
- Blinkende Punkte mit hartem Wechsel (steps)
- Isometrisches Raster
- Shader-Punktraster (Paper Shaders: Dot grid)
Umsetzung
Die CSS-Fassung kostet fast nichts: ein Hintergrundbild plus mask-image. Interaktiv: Canvas 2D mit Punkten im Rasterabstand. Pro Frame wird die gelerpte Mausposition nachgeführt und für jeden Punkt im Einflussradius f = 1 − smoothstep(0, R, d) berechnet, Radius = Grundradius + k·f. Canvas mit devicePixelRatio skalieren, sonst werden die Punkte unscharf oder es entsteht Moiré. Ruhige Punkte in einem gemeinsamen Pfad zeichnen und die Schleife im Ruhezustand anhalten. Punkte, Linien und Inhalte teilen sich einen Rasterursprung. Belege: Nothing (Punktraster per CSS), Linear (blinkende Punkte mit steps(1)).
Robust umsetzen
Telefon. Die Cursor-Reaktion entfällt, das statische Raster bleibt. Optional setzt ein Tippen die Wölbung kurz an die getippte Stelle.
Ohne Bewegung. Cursor-Reaktion und Blinken aus. Das Raster bleibt sichtbar.
Barrierefrei. Rein dekorativ: aria-hidden und pointer-events: none. Linien und Punkte hinter Text sehr leise halten (hier 9 bis 26 % Deckkraft) und zum Text hin ausblenden. Informationen, die auf dem Raster liegen (hier die Route), zusätzlich als echte Liste im Markup.
Code
<!DOCTYPE html>
<!--
Linien- und Punktraster
Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/linien-punkt-raster/
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>Linien- und Punktraster · 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: 'Bricolage Grotesque'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bricolage-grotesque:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; 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: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
--grund: #f8f8ec; --grund-tief: #eef0d8;
--navy: #142b50; --nacht: #0e1d38; --himmel: #3fbadf; --gelb: #ffd60a; --leise: #566175;
--linie: rgba(20, 43, 80, .14);
/* Raster: Punkt 1 px mit 0,5 px weicher Kante, Zelle 20 px; Linien alle 60 px */
--zelle: 20px; --zelle-linie: 60px;
--punkt: rgba(20, 43, 80, .26); --rasterlinie: rgba(20, 43, 80, .09);
--ox: 0px; --oy: 0px; /* Rasterursprung, setzt das Skript */
--fade: radial-gradient(ellipse at 66% 52%, #000 40%, transparent 75%);
--feder: cubic-bezier(0.34, 1.56, 0.64, 1); /* Buttons, 350 ms */
--aus: cubic-bezier(0.16, 1, 0.3, 1); /* Einblenden, expo.out */
--form: cubic-bezier(0.4, 0, 0.2, 1); /* Auswahl, 150 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--navy); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: hidden; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* ---------- Bühne ---------- */
.buehne {
position: relative; isolation: isolate; min-height: 100svh;
display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-rows: auto 1fr auto;
column-gap: 24px; padding: 0 clamp(20px, 4vw, 56px);
}
.raster { position: absolute; inset: 0; z-index: -1; pointer-events: none; -webkit-mask-image: var(--fade); mask-image: var(--fade); }
.raster > * { position: absolute; inset: 0; opacity: 0; transition: opacity .6s var(--aus); }
.raster canvas { width: 100%; height: 100%; display: block; }
/* Variante 2: reines CSS, ein Hintergrundbild */
.raster .punkte-css {
background-image: radial-gradient(circle at center, var(--punkt) 1px, transparent 1.5px);
background-size: var(--zelle) var(--zelle);
background-position: calc(var(--ox) - var(--zelle) / 2) calc(var(--oy) - var(--zelle) / 2);
}
/* Variante 3: zwei lineare Verläufe, dazu Plus-Marken an jedem zweiten Schnittpunkt */
.raster .linien {
background-image: linear-gradient(90deg, var(--rasterlinie) 1px, transparent 1px), linear-gradient(0deg, var(--rasterlinie) 1px, transparent 1px);
background-size: var(--zelle-linie) var(--zelle-linie);
background-position: var(--ox) var(--oy);
}
.marke { position: absolute; width: 11px; height: 11px; translate: -5px -5px; opacity: .3; }
.marke::before, .marke::after { content: ""; position: absolute; background: var(--navy); }
.marke::before { left: 5px; top: 0; width: 1px; height: 11px; }
.marke::after { top: 5px; left: 0; height: 1px; width: 11px; }
.marke.blinkt { animation: blinken 2800ms steps(1, end) infinite; }
@keyframes blinken { 0% { opacity: .3; } 50% { opacity: 1; } }
[data-variante="cursor"] .raster canvas,
[data-variante="statisch"] .raster .punkte-css,
[data-variante="linien"] .raster .linien,
[data-variante="linien"] .raster .marken { opacity: 1; }
.raster .marken { inset: 0; }
/* ---------- Kopfzeile ---------- */
.kopf { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 22px 0; }
.marke-logo { display: flex; align-items: center; gap: 11px; color: var(--navy); text-decoration: none; }
.marke-logo svg { width: 38px; height: 38px; flex: none; }
.marke-logo b { display: block; font: 700 21px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 32; letter-spacing: -.02em; }
.marke-logo small { display: block; font-size: 12.5px; color: var(--leise); margin-top: 3px; }
.nav { display: flex; gap: 30px; list-style: none; padding: 0; }
.nav a { color: var(--navy); text-decoration: none; font-size: 15.5px; font-weight: 500; }
.nav a:hover { text-decoration: underline; text-decoration-color: var(--himmel); text-decoration-thickness: 2px; text-underline-offset: 6px; }
.anmelden { display: inline-flex; align-items: center; gap: 8px; background: var(--navy); color: var(--grund); text-decoration: none; font-weight: 700; font-size: 15px; padding: 12px 18px; border-radius: 999px; transition: transform .35s var(--feder); }
.anmelden:hover { transform: translateY(-2px); }
/* ---------- Inhalt ---------- */
.inhalt { grid-column: 1 / span 6; align-self: center; padding: 24px 0 40px; container-type: inline-size; position: relative; z-index: 2; }
.hinweis { display: inline-flex; align-items: center; gap: 10px; background: var(--gelb); color: var(--navy); font-weight: 700; font-size: 14px; padding: 7px 13px 7px 9px; border-radius: 999px; }
.hinweis i { width: 8px; height: 8px; border-radius: 50%; background: var(--navy); }
h1 {
font: 700 min(clamp(2.5rem, 1rem + 6.5vw, 6rem), 15.2cqi)/0.98 'Bricolage Grotesque', sans-serif;
font-variation-settings: 'opsz' 96; letter-spacing: -.02em; hyphens: auto; margin: 22px 0 22px; text-wrap: balance;
}
h1 em { font-style: normal; white-space: nowrap; background: linear-gradient(transparent 62%, var(--gelb) 62%, var(--gelb) 92%, transparent 92%); padding: 0 .04em; }
.lead { font-size: 1.125rem; line-height: 1.6; color: var(--leise); max-width: 34ch; text-wrap: pretty; }
.lead b { color: var(--navy); }
.wahl { border: 0; padding: 0; margin: 28px 0 0; }
.wahl legend { font-size: 14px; font-weight: 700; margin-bottom: 10px; }
.klassen { display: flex; flex-wrap: wrap; gap: 8px; }
.klassen label { position: relative; }
.klassen input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.klassen span {
display: grid; place-items: center; min-width: 64px; height: 46px; padding: 0 16px; border-radius: 12px;
background: rgba(255, 255, 255, .78); box-shadow: inset 0 0 0 1.5px var(--linie);
font: 700 17px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 24;
transition: background-color .15s var(--form), color .15s var(--form), box-shadow .15s var(--form);
}
.klassen input:checked + span { background: var(--navy); color: var(--grund); box-shadow: none; }
.klassen input:focus-visible + span { outline: 3px solid var(--himmel); outline-offset: 2px; }
.preise { display: flex; flex-wrap: wrap; gap: 4px 22px; margin-top: 16px; font-size: 15px; color: var(--leise); font-variant-numeric: tabular-nums; }
.preise b { color: var(--navy); }
.aktion { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px; margin-top: 26px; }
.knopf { display: inline-flex; align-items: center; gap: 10px; background: var(--gelb); color: var(--navy); text-decoration: none; font-weight: 700; font-size: 16.5px; padding: 16px 24px; border-radius: 999px; box-shadow: inset 0 0 0 2px var(--navy); transition: transform .35s var(--feder); }
.knopf:hover { transform: translateY(-3px); }
.knopf svg { width: 18px; height: 18px; }
.anruf { color: var(--navy); font-size: 15px; text-decoration: none; }
.anruf b { font-variant-numeric: tabular-nums; text-decoration: underline; text-decoration-color: var(--himmel); text-decoration-thickness: 2px; text-underline-offset: 5px; }
/* ---------- Route auf dem Raster ---------- */
.routenfeld { grid-column: 8 / -1; grid-row: 2; margin: 28px 0 36px; min-height: 420px; }
.strasse { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; overflow: visible; }
.strasse .rand { fill: none; stroke: var(--grund); stroke-width: 22; stroke-linecap: round; stroke-linejoin: round; }
.strasse .belag { fill: none; stroke: var(--navy); stroke-width: 14; stroke-linecap: round; stroke-linejoin: round; }
.strasse .mitte { fill: none; stroke: var(--grund); stroke-width: 2; stroke-dasharray: 9 11; stroke-linecap: round; transition: opacity .8s var(--aus); }
.strasse.zeichnet .mitte { opacity: 0; }
.stationen { list-style: none; padding: 0; position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.station { position: absolute; left: 0; top: 0; width: 0; height: 0; }
.station .halt { position: absolute; left: -11px; top: -11px; width: 22px; height: 22px; border-radius: 50%; background: var(--grund); box-shadow: inset 0 0 0 4px var(--navy); }
.station .text { position: absolute; top: 20px; left: -12px; width: max-content; max-width: 210px; padding: 4px 6px; background: color-mix(in srgb, var(--grund) 86%, transparent); border-radius: 6px; }
.station.rechts .text { left: auto; right: -12px; text-align: right; }
.station.seitlich .text { top: -13px; left: 22px; }
.station .nr { display: block; font: 600 11px/1 'Geist Mono', monospace; letter-spacing: .14em; color: var(--leise); margin-bottom: 5px; }
.station b { display: block; font: 700 19px/1.1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 24; letter-spacing: -.01em; }
.station small { display: block; font-size: 13.5px; line-height: 1.35; color: var(--leise); margin-top: 3px; }
.station.jetzt .halt { background: var(--gelb); }
.station .tag { display: inline-block; margin-top: 7px; background: var(--gelb); font-weight: 700; font-size: 12.5px; padding: 3px 8px; border-radius: 5px; font-variant-numeric: tabular-nums; }
.station.ziel .halt { border-radius: 4px; background: conic-gradient(var(--navy) 25%, var(--grund) 0 50%, var(--navy) 0 75%, var(--grund) 0) 0 0 / 11px 11px; box-shadow: 0 0 0 3px var(--navy); }
.stationen .station { opacity: 0; translate: 0 8px; transition: opacity .8s var(--aus), translate .8s var(--aus); }
.stationen .station.da { opacity: 1; translate: 0 0; }
/* ---------- Demo-Steuerung ---------- */
.steuerung { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 28px; padding: 16px 0 22px; border-top: 1px solid var(--linie); position: relative; z-index: 3; background: var(--grund); }
.schalter { display: flex; align-items: center; gap: 4px; }
.schalter .mono { margin-right: 10px; }
.schalter button { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); background: none; border: 0; padding: 8px 10px; border-radius: 6px; cursor: pointer; transition: background-color .15s var(--form), color .15s var(--form); }
.schalter button[aria-pressed="true"] { background: var(--navy); color: var(--grund); }
.schalter button:focus-visible { outline: 2px solid var(--himmel); outline-offset: 2px; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.werte b { color: var(--navy); font-weight: 500; }
.variantenmarke { display: none; position: absolute; left: 50%; translate: -50% 0; top: 27px; z-index: 3; background: var(--navy); color: var(--grund); padding: 7px 10px; border-radius: 6px; }
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger.an { display: block; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .steuerung { display: none; }
.vorschau .variantenmarke { display: block; }
.vorschau .buehne { height: 100svh; grid-template-rows: auto 1fr; }
/* ---------- Schmal ---------- */
@media (max-width: 1100px) {
.nav { display: none; }
.routenfeld { grid-column: 7 / -1; }
.station .text { max-width: 180px; }
}
@media (min-width: 821px) and (max-height: 960px) {
.kopf { padding: 16px 0; }
.inhalt { padding: 8px 0 24px; }
h1 { font-size: min(clamp(2.5rem, 1rem + 6.5vw, 6rem), 15.2cqi, 9.6vh); margin: 16px 0 16px; }
.lead { font-size: 1.0625rem; max-width: 40ch; }
.wahl { margin-top: 20px; }
.wahl legend { margin-bottom: 8px; }
.preise { margin-top: 12px; }
.aktion { margin-top: 20px; }
.routenfeld { margin: 16px 0 28px; }
}
@media (max-width: 820px) {
:root { --fade: radial-gradient(ellipse at 50% 74%, #000 40%, transparent 75%); }
.buehne { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto auto; column-gap: 0; }
.inhalt, .routenfeld, .steuerung, .kopf { grid-column: 1; }
.inhalt { padding: 8px 0 8px; }
.routenfeld { grid-row: 3; min-height: 540px; margin: 28px 0 8px; }
.marke-logo small { display: none; }
.station .text { max-width: 230px; }
.station b { font-size: 17px; }
.station small { font-size: 13px; }
.vorschau .buehne { height: auto; }
}
@media (max-width: 420px) {
.klassen span { min-width: 0; padding: 0 14px; }
.knopf { width: 100%; justify-content: center; }
.schalter { flex-wrap: wrap; }
.schalter .mono { width: 100%; margin-bottom: 4px; }
}
/* Touch: kein Hover; Tippen setzt die Wölbung einmal */
@media (hover: none) {
.knopf:hover, .anmelden:hover { transform: none; }
}
/* Reduzierte Bewegung: Raster steht, nichts blinkt, Route ist sofort da */
:root.reduziert .marke.blinkt { animation: none; opacity: .45; }
:root.reduziert .stationen .station { transition: opacity .2s linear; translate: none; }
:root.reduziert .knopf, :root.reduziert .anmelden { transition: none; }
:root.reduziert .raster > * { transition: opacity .15s linear; }
</style>
</head>
<body>
<section class="buehne" data-buehne data-variante="cursor">
<div class="raster" aria-hidden="true">
<canvas data-leinwand></canvas>
<div class="punkte-css"></div>
<div class="linien"></div>
<div class="marken" data-marken></div>
</div>
<header class="kopf">
<a class="marke-logo" href="#start" aria-label="Kurvenlicht, Fahrschule in Kassel">
<svg viewBox="0 0 40 40" aria-hidden="true">
<path d="M21 5 L36 1 L36 15 Z" fill="#ffd60a"/>
<path d="M6 37 C6 22 12 12 24 8" fill="none" stroke="#142b50" stroke-width="7" stroke-linecap="round"/>
<path d="M6 37 C6 22 12 12 24 8" fill="none" stroke="#f8f8ec" stroke-width="1.4" stroke-dasharray="3 3.4"/>
</svg>
<span><b>Kurvenlicht</b><small>Fahrschule in Kassel</small></span>
</a>
<ul class="nav">
<li><a href="#klassen">Klassen & Preise</a></li>
<li><a href="#ablauf">Ablauf</a></li>
<li><a href="#theorie">Theorie-Termine</a></li>
<li><a href="#team">Fahrlehrer:innen</a></li>
</ul>
<a class="anmelden" href="#anmelden">Anmelden</a>
</header>
<div class="inhalt">
<p class="hinweis"><i></i><span>Neuer Theoriekurs ab <span data-termin>Mo, 9. November</span></span></p>
<h1>Führer­schein in Kassel. <em>Mit Plan</em> statt Bauchweh.</h1>
<p class="lead">Theorie in Wehlheiden, Fahrstunden ab deiner Haustür. Wähl deine Klasse und sieh <b>sofort Preis und freie Termine</b>.</p>
<fieldset class="wahl">
<legend>Welche Klasse brauchst du?</legend>
<div class="klassen" data-klassen>
<label><input type="radio" name="klasse" value="B" checked><span>B</span></label>
<label><input type="radio" name="klasse" value="B197"><span>B197</span></label>
<label><input type="radio" name="klasse" value="A"><span>A</span></label>
<label><input type="radio" name="klasse" value="BE"><span>BE</span></label>
</div>
<p class="preise" data-preise aria-live="polite"></p>
</fieldset>
<div class="aktion">
<a class="knopf" href="#beratung">Kostenlose Beratung buchen
<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 9h11M10 4.5 14.5 9 10 13.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
<a class="anruf" href="#anrufen">oder anrufen: <b>0561 739 20 18</b></a>
</div>
</div>
<div class="routenfeld" data-routenfeld aria-hidden="true"></div>
<svg class="strasse" data-strasse aria-hidden="true"><path class="rand" data-pfad/><path class="belag" data-pfad/><path class="mitte" data-pfad/></svg>
<ol class="stationen" data-stationen aria-label="In fünf Schritten zum Führerschein">
<li class="station"><span class="halt"></span><span class="text"><span class="nr">01</span><b>Anmelden</b><small>online oder im Büro, 10 Minuten</small></span></li>
<li class="station"><span class="halt"></span><span class="text"><span class="nr">02</span><b>Sehtest & Erste Hilfe</b><small>ein Samstag, wir buchen mit</small></span></li>
<li class="station jetzt"><span class="halt"></span><span class="text"><span class="nr">03</span><b>Theorie</b><small>14 Doppelstunden, abends</small><span class="tag" data-tag>Start Mo, 9.11.</span></span></li>
<li class="station"><span class="halt"></span><span class="text"><span class="nr">04</span><b>Fahrstunden</b><small>ab der Haustür, auch Automatik</small></span></li>
<li class="station ziel"><span class="halt"></span><span class="text"><span class="nr">05</span><b>Prüfung</b><small>wir fahren mit hin</small></span></li>
</ol>
<p class="variantenmarke mono" data-marke-variante>Punkte · Cursor</p>
<div class="steuerung">
<div class="schalter" role="group" aria-label="Rastervariante">
<span class="mono">Raster</span>
<button type="button" data-v="cursor" aria-pressed="true">Punkte · Cursor</button>
<button type="button" data-v="statisch" aria-pressed="false">Punkte · CSS</button>
<button type="button" data-v="linien" aria-pressed="false">Linien + Marken</button>
</div>
<p class="werte mono" data-werte aria-live="polite"></p>
</div>
</section>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2.5 1.8 17 10.2l-6.5 1.4-3.1 6.1Z" fill="#142b50" stroke="#f8f8ec" stroke-width="1.3" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const buehne = document.querySelector('[data-buehne]');
const leinwand = document.querySelector('[data-leinwand]');
const ctx = leinwand.getContext('2d');
const markenEl = document.querySelector('[data-marken]');
const feld = document.querySelector('[data-routenfeld]');
const strasse = document.querySelector('[data-strasse]');
const pfade = [...strasse.querySelectorAll('[data-pfad]')];
const stationen = [...document.querySelectorAll('.station')];
const zeiger = document.querySelector('[data-zeiger]');
const werteEl = document.querySelector('[data-werte]');
const markeVariante = document.querySelector('[data-marke-variante]');
const knoepfe = [...document.querySelectorAll('[data-v]')];
// Die Werte der Demo (Token für den Nachbau)
const W = {
zelle: 20, // Punktabstand px (Messwert 16–24)
zelleLinie: 60, // Linienabstand px (Messwert 32–64), = 3 Punktzellen
punkt: 1, // Grundradius px, weiche Kante bis 1,5 px
einfluss: 140, // Einflussradius px (Messwert 80–160)
wachsen: 2.4, // zusätzlicher Radius im Zentrum px
ausweichen: 6, // Verschiebung nach außen px
lerp: 0.12, // pro Frame bei 60 fps (Messwert 0,1–0,15)
alpha: 0.26, // Grunddeckkraft der Punkte
alphaMax: 0.9,
};
const NAMEN = { cursor: 'Punkte · Cursor', statisch: 'Punkte · CSS', linien: 'Linien + Marken' };
/* ---------- Variante und Werte-Anzeige ---------- */
let variante = 'cursor';
function setzeVariante(v) {
variante = v;
buehne.dataset.variante = v;
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.v === v)));
markeVariante.textContent = NAMEN[v];
zeigeWerte();
if (v === 'cursor') starten(); else malen(true);
}
function zeigeWerte() {
const t = {
cursor: reduziert()
? ['Raster <b>20 px</b>', 'Punkt <b>1 px</b> / Kante <b>1,5 px</b>', 'Deckkraft <b>26 %</b>', '<b>Cursor-Reaktion aus</b>']
: ['Canvas 2D · DPR', 'Raster <b>20 px</b>', 'Einfluss <b>140 px</b>', 'Lerp <b>0,12</b>', 'r <b>1 → 3,4 px</b> · smoothstep', 'Ausweichen <b>6 px</b>'],
statisch: ['radial-gradient <b>1 px / 1,5 px</b>', 'background-size <b>20 px</b>', 'mask <b>40 % → 75 %</b>', 'Deckkraft <b>26 %</b>'],
linien: ['Linien <b>1 px</b> · <b>60 px</b>', 'Deckkraft <b>9 %</b>', 'Plus-Marke <b>11 px</b> alle <b>120 px</b>', reduziert() ? '<b>Blinken aus</b>' : 'Blinken <b>0,3 ↔ 1</b> · <b>2800 ms steps(1)</b>'],
}[variante];
werteEl.innerHTML = t.map(s => `<span>${s}</span>`).join('');
}
knoepfe.forEach(k => k.addEventListener('click', () => { if (laeuftVor) beendeVorfuehrung(); setzeVariante(k.dataset.v); }));
/* ---------- Klassenwahl ---------- */
const PREISE = {
B: { grund: 390, stunde: 62, sonder: 74, termin: 'Mo, 9. November', tag: 'Start Mo, 9.11.' },
B197: { grund: 420, stunde: 62, sonder: 74, termin: 'Mo, 9. November', tag: 'Start Mo, 9.11.' },
A: { grund: 350, stunde: 68, sonder: 80, termin: 'Mi, 11. November', tag: 'Start Mi, 11.11.' },
BE: { grund: 250, stunde: 85, sonder: 95, termin: 'Sa, 14. November', tag: 'Start Sa, 14.11.' },
};
const preiseEl = document.querySelector('[data-preise]');
function zeigePreise() {
const k = document.querySelector('[name=klasse]:checked').value, p = PREISE[k];
preiseEl.innerHTML = `<span>Grundbetrag <b>${p.grund} €</b></span><span>Fahrstunde 45 min <b>${p.stunde} €</b></span><span>Sonderfahrt <b>${p.sonder} €</b></span>`;
document.querySelector('[data-termin]').textContent = p.termin;
document.querySelector('[data-tag]').textContent = p.tag;
}
document.querySelector('[data-klassen]').addEventListener('change', zeigePreise);
zeigePreise();
/* ---------- Geometrie: Rasterursprung, Route auf Schnittpunkte ---------- */
let B = { w: 0, h: 0 }, ox = 0, oy = 0, dpr = 1, spalten = 0, zeilen = 0;
// Route im Routenfeld (Anteile), Stationen liegen auf geraden Strecken
const BREIT = { reihen: [0.04, 0.42, 0.78], wege: [[0.03, 0], [0.97, 0], [0.97, 1], [0.03, 1], [0.03, 2], [0.97, 2]],
halte: [[0.03, 0], [0.6, 0], [0.5, 1], [0.15, 2], [0.97, 2]] };
// Telefon: senkrechte Straße links, Texte daneben, am Ende eine Kurve zum Ziel
const SCHMAL = { reihen: [0.03, 0.25, 0.47, 0.69, 0.91], wege: [[0.16, 0], [0.16, 4], [0.7, 4]],
halte: [[0.16, 0], [0.16, 1], [0.16, 2], [0.16, 3], [0.7, 4]], seitlich: [1, 1, 1, 1, 0] };
function geometrie() {
const r = buehne.getBoundingClientRect();
B = { w: buehne.clientWidth, h: buehne.clientHeight };
dpr = Math.min(2, window.devicePixelRatio || 1);
leinwand.width = Math.round(B.w * dpr); leinwand.height = Math.round(B.h * dpr);
// Ursprung so, dass das Raster mittig sitzt (Linienzelle = 3 Punktzellen)
ox = Math.round((B.w % W.zelleLinie) / 2); oy = Math.round((B.h % W.zelleLinie) / 2);
buehne.style.setProperty('--ox', ox + 'px'); buehne.style.setProperty('--oy', oy + 'px');
spalten = Math.ceil(B.w / W.zelle) + 1; zeilen = Math.ceil(B.h / W.zelle) + 1;
// Plus-Marken an jedem zweiten Linienschnitt (120 px)
const schritt = W.zelleLinie * 2;
let m = '', i = 0;
for (let y = oy; y < B.h; y += schritt) for (let x = ox; x < B.w; x += schritt) {
const blink = (i * 7 + Math.floor(y / schritt) * 3) % 4 === 0; // jede vierte blinkt, versetzt
const delay = -((i * 467) % 2800);
m += `<i class="marke${blink ? ' blinkt' : ''}" style="left:${x}px;top:${y}px;animation-delay:${delay}ms"></i>`; i++;
}
markenEl.innerHTML = m;
route();
malen(true);
}
const einrasten = (v, o, s) => o + Math.round((v - o) / s) * s;
let pfadLaenge = 0, routePunkte = [];
function route() {
const f = feld.getBoundingClientRect(), b = buehne.getBoundingClientRect();
const L = innerWidth <= 820 ? SCHMAL : BREIT, s = W.zelleLinie;
const fx = a => einrasten(f.left - b.left + a * f.width, ox, s);
const fy = n => einrasten(f.top - b.top + L.reihen[n] * f.height, oy, s);
const wege = L.wege.map(([a, n]) => [fx(a), fy(n)]);
// Ecken runden (Radius 30 px), Strecken waagerecht/senkrecht
const R = 30;
let d = `M${wege[0][0]} ${wege[0][1]}`;
for (let i = 1; i < wege.length; i++) {
const [x, y] = wege[i], [px, py] = wege[i - 1];
if (i < wege.length - 1) {
const [nx, ny] = wege[i + 1];
const r = Math.min(R, Math.abs(x - px || y - py) / 2, Math.abs(nx - x || ny - y) / 2);
const ein = [x - Math.sign(x - px) * r, y - Math.sign(y - py) * r];
const aus = [x + Math.sign(nx - x) * r, y + Math.sign(ny - y) * r];
d += ` L${ein[0]} ${ein[1]} Q${x} ${y} ${aus[0]} ${aus[1]}`;
} else d += ` L${x} ${y}`;
}
pfade.forEach(p => p.setAttribute('d', d));
pfadLaenge = pfade[1].getTotalLength();
routePunkte = L.halte.map(([a, n]) => [fx(a), fy(n)]);
stationen.forEach((st, i) => {
const [x, y] = routePunkte[i];
st.style.left = x + 'px'; st.style.top = y + 'px';
// Text nach rechts, solange Platz ist; nur am rechten Rand rechtsbündig
// Platz bis zur rechten Straßenkante (halbe Straßenbreite 11 px plus Luft)
const rand = Math.max(...wege.map(w => w[0])), platz = (rand > x + 1 ? rand - 22 : f.right - b.left) - x;
st.classList.toggle('rechts', platz < 120);
const seitlich = !!(L.seitlich && L.seitlich[i]);
st.classList.toggle('seitlich', seitlich);
st.querySelector('.text').style.maxWidth = (platz < 120 || seitlich) ? '' : Math.max(120, Math.min(210, platz + 12)) + 'px';
});
}
/* ---------- Canvas: Punkte mit Cursor-Wölbung ---------- */
const ziel = { x: -999, y: -999, s: 0 }, ist = { x: -999, y: -999, s: 0 };
let laeuft = false, letzte = 0;
const glatt = t => t * t * (3 - 2 * t);
function malen(sofort) {
if (variante !== 'cursor' && !sofort) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, B.w, B.h);
const S = W.zelle, R = W.einfluss, s = reduziert() ? 0 : ist.s;
const x0 = ox % S, y0 = oy % S;
// ruhige Punkte in einem Pfad, bewegte einzeln
ctx.fillStyle = `rgba(20, 43, 80, ${W.alpha})`;
ctx.beginPath();
const aktive = [];
for (let j = 0; j < zeilen; j++) {
const y = y0 + j * S;
for (let i = 0; i < spalten; i++) {
const x = x0 + i * S;
const dx = x - ist.x, dy = y - ist.y;
if (s > 0.002 && Math.abs(dx) < R && Math.abs(dy) < R) {
const dist = Math.hypot(dx, dy);
if (dist < R) { aktive.push(x, y, dx, dy, dist); continue; }
}
ctx.moveTo(x + W.punkt + .25, y); ctx.arc(x, y, W.punkt + .25, 0, 6.2832);
}
}
ctx.fill();
for (let k = 0; k < aktive.length; k += 5) {
const x = aktive[k], y = aktive[k + 1], dx = aktive[k + 2], dy = aktive[k + 3], dist = aktive[k + 4];
const t = dist / R, f = (1 - glatt(t)) * s;
const weg = W.ausweichen * f * Math.min(1, 2 * t);
const nx = dist ? dx / dist : 0, ny = dist ? dy / dist : 0;
const r = W.punkt + .25 + W.wachsen * f;
const m = f * f; // zur Mitte hin Himmelblau
ctx.fillStyle = `rgba(${20 + 43 * m | 0}, ${43 + 143 * m | 0}, ${80 + 143 * m | 0}, ${W.alpha + (W.alphaMax - W.alpha) * f})`;
ctx.beginPath(); ctx.arc(x + nx * weg, y + ny * weg, r, 0, 6.2832); ctx.fill();
}
}
function bild(t) {
const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
const k = 1 - Math.pow(1 - W.lerp, dt);
ist.x += (ziel.x - ist.x) * k; ist.y += (ziel.y - ist.y) * k; ist.s += (ziel.s - ist.s) * k;
malen();
const ruhig = Math.abs(ziel.x - ist.x) < .2 && Math.abs(ziel.y - ist.y) < .2 && Math.abs(ziel.s - ist.s) < .003;
if (ruhig || variante !== 'cursor' || reduziert()) { laeuft = false; malen(true); return; }
requestAnimationFrame(bild);
}
function starten() { if (!laeuft && variante === 'cursor' && !reduziert()) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
function zielAuf(x, y, s = 1) {
if (ist.s < 0.02) { ist.x = x; ist.y = y; } // nicht vom alten Ort hereinziehen
ziel.x = x; ziel.y = y; ziel.s = s; starten();
}
// Maus: Wölbung folgt dem Zeiger
buehne.addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' || laeuftVor) return;
const b = buehne.getBoundingClientRect(); zielAuf(e.clientX - b.left, e.clientY - b.top);
});
buehne.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !laeuftVor) { ziel.s = 0; starten(); } });
// Touch: Tippen setzt die Wölbung, sie klingt wieder ab
let tippUhr = null;
buehne.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse' || laeuftVor || e.target.closest('a, button, label, input')) return;
const b = buehne.getBoundingClientRect(); zielAuf(e.clientX - b.left, e.clientY - b.top);
clearTimeout(tippUhr); tippUhr = setTimeout(() => { ziel.s = 0; starten(); }, 700);
});
/* ---------- Route zeichnen ---------- */
let zeichenUhr = null;
function routeZeigen() {
cancelAnimationFrame(zeichenUhr);
const belag = pfade.slice(0, 2);
if (reduziert()) {
belag.forEach(p => { p.style.strokeDasharray = ''; p.style.strokeDashoffset = ''; });
strasse.classList.remove('zeichnet');
stationen.forEach(s => s.classList.add('da'));
return;
}
strasse.classList.add('zeichnet');
stationen.forEach(s => s.classList.remove('da'));
const L = pfadLaenge, start = performance.now(), DAUER = 1600;
const expo = t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t); // expo.out
// Anteil der Strecke bis zur Station (für das Erscheinen)
const anteile = routePunkte.map(([x, y]) => naechsterAnteil(x, y));
const schritt = now => {
const p = Math.min(1, (now - start) / DAUER), e = expo(p);
belag.forEach(pf => { pf.style.strokeDasharray = `${L} ${L}`; pf.style.strokeDashoffset = String(L * (1 - e)); });
stationen.forEach((s, i) => { if (e >= anteile[i] - 0.02) s.classList.add('da'); });
if (p < 1) zeichenUhr = requestAnimationFrame(schritt);
else { belag.forEach(pf => { pf.style.strokeDasharray = ''; pf.style.strokeDashoffset = ''; }); strasse.classList.remove('zeichnet'); }
};
zeichenUhr = requestAnimationFrame(schritt);
}
function naechsterAnteil(x, y) {
let best = 0, bd = Infinity;
for (let l = 0; l <= pfadLaenge; l += 6) {
const p = pfade[1].getPointAtLength(l), d = (p.x - x) ** 2 + (p.y - y) ** 2;
if (d < bd) { bd = d; best = l; }
}
return best / pfadLaenge;
}
/* ---------- Vorführung: ein Kurvenlicht fährt die Route ab ---------- */
let laeuftVor = false, vorUhr = null, vorStart = 0, phaseVor = '';
const PHASEN = [['cursor', 7600], ['linien', 3600], ['statisch', 2800]];
const RUNDE = PHASEN.reduce((a, p) => a + p[1], 0);
function vorfuehren() {
pause();
laeuftVor = true; html.classList.add('vorfuehrung');
vorStart = performance.now() - (vorPausiert || 0); vorPausiert = 0;
const schritt = now => {
const t = (now - vorStart) % RUNDE;
let acc = 0, ph = PHASEN[0], lokal = 0;
for (const p of PHASEN) { if (t < acc + p[1]) { ph = p; lokal = t - acc; break; } acc += p[1]; }
if (phaseVor !== ph[0]) { phaseVor = ph[0]; setzeVariante(ph[0]); if (ph[0] === 'cursor') { ist.s = 0; ziel.s = 0; } }
if (ph[0] === 'cursor') {
// 0,4 s Anlauf, 6,4 s Fahrt entlang der Straße, dann Zeiger weg
const q = (lokal - 400) / 6400;
if (q >= 0 && q <= 1) {
const e = q < .5 ? 2 * q * q : 1 - Math.pow(-2 * q + 2, 2) / 2; // sanft an und aus
const p = pfade[1].getPointAtLength(e * pfadLaenge), b = buehne.getBoundingClientRect();
const wackel = Math.sin(q * Math.PI * 6) * 26; // etwas neben der Straße
zielAuf(p.x, p.y - 34 + wackel * .4, 1);
zeiger.classList.add('an');
zeiger.style.transform = `translate(${b.left + ziel.x - 3}px, ${b.top + ziel.y - 2}px)`;
} else { ziel.s = 0; starten(); zeiger.classList.remove('an'); }
} else zeiger.classList.remove('an');
vorUhr = requestAnimationFrame(schritt);
};
vorUhr = requestAnimationFrame(schritt);
}
let vorPausiert = 0;
function pause() {
if (vorUhr) { cancelAnimationFrame(vorUhr); vorUhr = null; vorPausiert = (performance.now() - vorStart) % RUNDE; }
}
function weiter() { if (!vorUhr && laeuftVor) vorfuehren(); }
function beendeVorfuehrung() {
pause(); vorPausiert = 0; laeuftVor = false; phaseVor = '';
html.classList.remove('vorfuehrung'); zeiger.classList.remove('an'); ziel.s = 0; starten();
}
function neustart() {
beendeVorfuehrung();
setzeVariante(variante);
ist.s = ziel.s = 0; malen(true);
routeZeigen();
}
/* ---------- Start ---------- */
let gr = 0;
addEventListener('resize', () => { cancelAnimationFrame(gr); gr = requestAnimationFrame(geometrie); });
let warReduziert = reduziert();
new MutationObserver(() => {
if (reduziert() === warReduziert) return;
warReduziert = reduziert(); zeigeWerte(); malen(true); routeZeigen();
}).observe(html, { attributes: true, attributeFilter: ['class'] });
geometrie(); setzeVariante('cursor'); routeZeigen();
(document.fonts && document.fonts.ready || Promise.resolve()).then(() => { geometrie(); });
})();
</script>
</body>
</html>