Standortfinder als Liste
Standorte als Liste mit Öffnungsstatus; erst nach Klick auf „Meinen Standort verwenden“ oder nach PLZ-Eingabe kommen Entfernungen dazu und die Liste ordnet sich nach Nähe, die Karte lädt erst nach einem zweiten Klick.
Ab vier Standorten oder wenn die Lage im Umland zählt: Praxisverbünde, Filialen, Makler mit Büros.
Mehrere Standorte stehen als Liste mit Adresse, Telefon und Öffnungsstatus („Geöffnet bis 19:00“, „Schließt in 20 Min.“, „Öffnet morgen 8:00“). Entfernungen kommen erst nach einer Handlung dazu: Knopf „Meinen Standort verwenden“ (der Browser fragt erst dann) oder Postleitzahl. Danach ordnet sich die Liste nach Nähe. Eine selbst gezeichnete Übersicht zeigt die Lage ohne Daten an Dritte; die Karte des Anbieters lädt erst nach einem zweiten Klick.
Die Liste beantwortet die eigentlichen Fragen: Welcher Standort ist nah, hat er jetzt offen, wie rufe ich an. Niemand wird beim Laden nach seinem Standort gefragt, und der Kartenanbieter bekommt erst Daten, wenn jemand die Karte wirklich will.
Wann einsetzen
Praxen, Ketten, Gastronomie und Makler mit drei oder mehr Standorten; überall, wo Menschen den nächsten offenen Ort suchen.
Wann nicht
Bei einem einzigen Standort (dann Anfahrt mit Karte per 2-Klick, siehe karte-zwei-klick). Bei sehr vielen Standorten (über 50) braucht es eine Suche mit Server und Kartenclustern statt einer Liste.
Varianten
- Liste mit gezeichneter Übersicht und Karte per 2-Klick
- Nur Liste (zwei bis vier Standorte), Entfernung und Status bleiben
- Mit Filter nach Leistung oder „Jetzt geöffnet“
Umsetzung
Standorte als Daten (Name, Adresse, lat/lon, Telefon, Wochenzeiten in Minuten, Sonderzeiten). Öffnungsstatus aus der Ortszeit: Intl.DateTimeFormat mit timeZone Europe/Berlin, Feiertage des Bundeslands als Liste; „schließt bald“ ab 30 Minuten vor Schluss; die Statuszeile hat eine feste Mindestbreite, mit echter Zeit jede Minute neu. Ortung nur im Click-Handler per navigator.geolocation.getCurrentPosition() (nur über HTTPS), Ablehnung und Fehler mit Text und Rückfall auf die PLZ. PLZ über eine kleine Tabelle PLZ → Mittelpunkt. Entfernung per Haversine als Luftlinie. Umordnen per FLIP (Web Animations API, 250 ms). Übersicht als eigene SVG mit Markern, die den Listeneintrag hervorheben. Die Karte des Anbieters erst nach Klick auf „Karte laden“ als iframe einsetzen (in der Demo nachgebildet), mit „Karte entfernen“. Standort nicht speichern. Datenschutztext und Kartenanbieter vom Kunden prüfen lassen.
Robust umsetzen
Telefon. Ortung und PLZ oben, Liste darunter, Übersicht unter der Liste. Telefon als tel:-Link, Route als geo:-Link für die Karten-App, Ziele mindestens 44 px.
Ohne Bewegung. Liste ordnet sich ohne Gleiten um; Marker und Karte erscheinen ohne Bewegung.
Barrierefrei. Liste als <ol> mit Überschrift je Standort. Nach dem Sortieren meldet eine role="status"-Zeile „Sortiert nach Entfernung von 31134“. Öffnungsstatus als Text, Farbe nur zusätzlich. Ortungsknopf ist ein echter <button>, Fehler werden als Text gemeldet. Die SVG-Übersicht ist ergänzend (aria-hidden oder mit Text-Alternative); alle Infos stehen auch in der Liste.
Code
<!DOCTYPE html>
<!--
Standortfinder als Liste
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/standortfinder-liste/
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>Standortfinder als Liste · 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: 'Hedvig Letters Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hedvig-letters-serif:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Google Sans Flex'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 1000; 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 {
/* Rezept Praxis: gedecktes Grün, warme Off-Whites, warmer Akzent nur für Hinweise */
--grund: #f5f7f7; --karte: #ffffff; --warm: #e2e0de; --marke: #005450; --marke-hell: #e3eeec; --text: #363b3b; --leise: #596666; --linie: #d9dddc;
--akzent: #ffc776; --wasser: #bfd6d3; --wald: #dfe5dc; --land: #eceeea;
--kurve: cubic-bezier(0.2, 0, 0.38, 0.9); /* Token tech-präzise */
--ui: 150ms; --ordnen: 250ms; --ein: 400ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Google Sans Flex', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button, input { font: inherit; color: inherit; }
.serif { font-family: 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; font-weight: 400; letter-spacing: -.01em; }
.ziffern { font-variant-numeric: tabular-nums; }
.label { font-size: 13px; font-weight: 500; letter-spacing: .04em; color: var(--leise); }
.mono { font: 500 10px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 3px solid var(--marke); outline-offset: 2px; }
a { color: var(--marke); }
/* Kopf */
.kopf { display: flex; align-items: center; gap: 16px; padding: 14px clamp(16px, 3vw, 40px); background: var(--grund); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 10px; margin-right: auto; text-decoration: none; color: var(--marke); }
.marke svg { width: 30px; height: 30px; }
.marke b { font: 400 22px/1 'Hedvig Letters Serif', serif; font-variation-settings: 'opsz' 24; }
.marke span { display: block; font-size: 12px; color: var(--leise); margin-top: 3px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 10px 18px; border-radius: 999px; border: 0; background: var(--marke); color: #fff; font-weight: 500; font-size: 15.5px; text-decoration: none; cursor: pointer; transition: background-color var(--ui) var(--kurve), scale var(--ui) var(--kurve); }
.knopf:hover { background: #00413e; }
.knopf:active, .knopf.gedrueckt { scale: .97; }
.knopf.leicht { background: transparent; color: var(--marke); box-shadow: inset 0 0 0 1.5px var(--marke); }
.knopf.leicht:hover { background: var(--marke-hell); }
.knopf svg { width: 18px; height: 18px; flex: none; }
.knopf .kurz { display: none; }
/* Bühne */
.buehne { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(320px, .85fr); gap: clamp(20px, 3vw, 44px); padding: clamp(20px, 3vw, 40px) clamp(16px, 3vw, 40px) 64px; max-width: 1320px; margin: 0 auto; align-items: start; }
.titel h1 { font-size: clamp(2rem, 1.4rem + 2.4vw, 3.2rem); line-height: 1.08; color: var(--marke); text-wrap: balance; }
.titel p { font-size: 17px; line-height: 1.6; color: var(--leise); max-width: 52ch; margin-top: 10px; }
/* Suche */
.suche { margin-top: 22px; padding: 16px; background: var(--karte); border-radius: 18px; box-shadow: 0 1px 0 var(--linie), 0 0 0 1px var(--linie); display: grid; gap: 12px; }
.suche-zeile { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; }
.oder { font-size: 14px; color: var(--leise); }
.plz { display: flex; align-items: stretch; border-radius: 999px; box-shadow: inset 0 0 0 1.5px #b6c0bf; background: var(--grund); transition: box-shadow var(--ui) var(--kurve); }
.plz:focus-within { box-shadow: inset 0 0 0 2px var(--marke); }
.plz input { width: 120px; border: 0; background: none; padding: 10px 6px 10px 18px; font-size: 17px; outline: none; letter-spacing: .04em; }
.plz button { border: 0; border-radius: 999px; margin: 4px; padding: 0 14px; background: var(--marke-hell); color: var(--marke); font-weight: 500; cursor: pointer; transition: background-color var(--ui) var(--kurve); }
.plz button:hover { background: #cfe3df; }
.schalter { display: inline-flex; align-items: center; gap: 10px; margin-left: auto; cursor: pointer; font-size: 15px; }
.schalter input { appearance: none; width: 42px; height: 24px; border-radius: 999px; background: #c8cfce; position: relative; cursor: pointer; transition: background-color var(--ordnen) var(--kurve); }
.schalter input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: translate var(--ordnen) var(--kurve); }
.schalter input:checked { background: var(--marke); }
.schalter input:checked::after { translate: 18px 0; }
.status { display: flex; align-items: center; gap: 10px; min-height: 24px; font-size: 14.5px; color: var(--leise); }
.status.fehler { color: #8a3b12; }
.status .weg { margin-left: auto; background: none; border: 0; color: var(--marke); text-decoration: underline; cursor: pointer; font-size: 14px; }
.status .punkt { width: 10px; height: 10px; border-radius: 50%; background: var(--marke); flex: none; box-shadow: 0 0 0 4px var(--marke-hell); }
/* Simulierte Browser-Abfrage */
.abfrage { display: grid; gap: 10px; padding: 14px 16px; border-radius: 12px; background: #2b3131; color: #f2f4f4; font-size: 14.5px; max-width: 360px; box-shadow: 0 10px 30px rgba(0, 40, 38, .2); animation: ein var(--ein) var(--kurve); }
.abfrage[hidden] { display: none; }
.abfrage small { font-size: 11.5px; color: rgba(242, 244, 244, .6); }
.abfrage div { display: flex; gap: 8px; justify-content: flex-end; }
.abfrage button { border: 0; border-radius: 6px; padding: 7px 12px; cursor: pointer; background: rgba(255,255,255,.12); color: #fff; }
.abfrage button.ja { background: #8fd3c8; color: #0e2b29; font-weight: 600; }
@keyframes ein { from { opacity: 0; translate: 0 8px; } }
/* Liste */
.liste { list-style: none; display: grid; gap: 10px; margin-top: 16px; }
.ort { position: relative; background: var(--karte); border-radius: 18px; box-shadow: 0 0 0 1px var(--linie); padding: 18px 20px 16px 64px; transition: box-shadow var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.ort[hidden] { display: none; }
.ort.aktiv { box-shadow: 0 0 0 2px var(--marke); }
.nr { position: absolute; left: 18px; top: 18px; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--marke-hell); color: var(--marke); font-weight: 600; font-size: 15px; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.ort.aktiv .nr, .ort.naechster .nr { background: var(--marke); color: #fff; }
.ort-kopf { display: grid; grid-template-columns: 1fr auto; gap: 4px 16px; align-items: start; }
.ort h3 { font: 400 23px/1.15 'Hedvig Letters Serif', serif; font-variation-settings: 'opsz' 24; color: var(--marke); }
.ort h3 small { font: 500 12px/1 'Google Sans Flex', sans-serif; letter-spacing: .02em; color: #6b5317; background: var(--akzent); padding: 4px 8px 3px; border-radius: 999px; margin-left: 8px; vertical-align: 4px; }
.ort h3, .adresse { grid-column: 1; }
.adresse { font-size: 15px; color: var(--leise); }
.entfernung { grid-row: 1 / span 2; grid-column: 2; text-align: right; min-width: 78px; }
.entfernung b { display: block; font: 400 24px/1 'Hedvig Letters Serif', serif; color: var(--text); }
.entfernung span { font-size: 12px; color: var(--leise); }
.entfernung:empty { display: none; }
.entfernung.neu { animation: ein var(--ein) var(--kurve); }
.zeile { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-top: 12px; min-height: 30px; }
.offen-status { display: inline-flex; align-items: center; gap: 8px; font-size: 14.5px; font-weight: 500; min-width: 15.5em; }
.offen-status i { width: 9px; height: 9px; border-radius: 50%; background: #9aa5a4; flex: none; }
.offen-status.auf i { background: #1a8a5a; box-shadow: 0 0 0 3px #d8efe5; }
.offen-status.bald i { background: #d9921f; box-shadow: 0 0 0 3px #fbecd0; }
.offen-status.zu { color: var(--leise); font-weight: 400; }
.aktionen { display: flex; gap: 6px; margin-left: auto; }
.aktionen a, .aktionen button { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 6px 12px; border-radius: 999px; border: 0; background: var(--grund); color: var(--marke); font-size: 14px; font-weight: 500; text-decoration: none; cursor: pointer; transition: background-color var(--ui) var(--kurve); }
.aktionen a:hover, .aktionen button:hover { background: var(--marke-hell); }
.aktionen svg { width: 15px; height: 15px; }
.zeiten-knopf[aria-expanded="true"] svg { rotate: 180deg; }
.zeiten-knopf svg { transition: rotate var(--ordnen) var(--kurve); }
.zeiten { display: grid; grid-template-rows: 0fr; transition: grid-template-rows var(--ordnen) var(--kurve); }
.zeiten > div { overflow: hidden; min-height: 0; }
.ort.zeiten-auf .zeiten { grid-template-rows: 1fr; }
.zeiten table { border-collapse: collapse; font-size: 14px; margin-top: 12px; width: min(100%, 360px); }
.zeiten td { padding: 4px 0; border-bottom: 1px solid var(--linie); }
.zeiten td + td { text-align: right; }
.zeiten tr.heute td { font-weight: 600; color: var(--marke); }
.zeiten p { font-size: 13px; color: var(--leise); margin-top: 8px; }
.leer { padding: 26px 22px; border-radius: 18px; background: var(--warm); display: grid; gap: 10px; }
.leer[hidden] { display: none; }
.leer h3 { font: 400 22px/1.2 'Hedvig Letters Serif', serif; color: var(--marke); }
.leer p { font-size: 15px; line-height: 1.55; }
/* Übersicht */
.uebersicht { position: sticky; top: 16px; border-radius: 22px; overflow: hidden; background: var(--land); box-shadow: 0 0 0 1px var(--linie); }
.uebersicht svg { display: block; width: 100%; height: auto; }
.uebersicht .titelzeile { position: absolute; left: 14px; top: 14px; right: 100px; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: center; gap: 8px; pointer-events: none; }
.uebersicht .titelzeile span { background: rgba(255,255,255,.88); padding: 6px 10px; border-radius: 999px; font-size: 12.5px; color: var(--leise); }
.pin { cursor: pointer; }
.pin circle.ring { fill: var(--karte); stroke: var(--marke); stroke-width: 2; transition: fill var(--ui) var(--kurve); }
.pin text { font: 600 12px/1 'Google Sans Flex', sans-serif; fill: var(--marke); text-anchor: middle; dominant-baseline: central; pointer-events: none; transition: fill var(--ui) var(--kurve); }
.pin.aktiv circle.ring, .pin:hover circle.ring, .pin.naechster circle.ring { fill: var(--marke); }
.pin.aktiv text, .pin:hover text, .pin.naechster text { fill: #fff; }
.pin.aus { opacity: .3; }
.pin:focus-visible { outline: none; }
.pin:focus-visible circle.ring { stroke-width: 4; }
.ich { opacity: 0; transition: opacity var(--ein) var(--kurve); }
.ich.an { opacity: 1; }
.ich .welle { fill: var(--marke); opacity: .18; transform-origin: center; transform-box: fill-box; animation: welle 2.4s var(--kurve) infinite; }
@keyframes welle { from { scale: .4; opacity: .35; } to { scale: 1.6; opacity: 0; } }
.reduziert .ich .welle { animation: none; opacity: .15; }
.detail-lage { opacity: 0; transition: opacity var(--ein) var(--kurve); }
.anbieter .detail-lage { opacity: 1; }
.zwei-klick { padding: 14px 16px 16px; background: var(--karte); border-top: 1px solid var(--linie); display: grid; gap: 10px; transition: background-color var(--ein) var(--kurve); }
.zwei-klick p { font-size: 14px; line-height: 1.5; }
.zwei-klick .reihe { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.zwei-klick .geladen { display: none; }
.anbieter .zwei-klick .vorher { display: none; }
.anbieter .zwei-klick .geladen { display: flex; }
.anbieter .zwei-klick { background: var(--marke-hell); }
.anbieter-leiste { position: absolute; right: 12px; top: 12px; display: flex; gap: 6px; align-items: center; opacity: 0; transition: opacity var(--ein) var(--kurve); pointer-events: none; }
.anbieter .anbieter-leiste { opacity: 1; pointer-events: auto; }
.anbieter-leiste span { font-size: 11px; background: rgba(255,255,255,.9); padding: 4px 8px; border-radius: 6px; color: var(--leise); }
.anbieter-leiste button { width: 34px; height: 34px; border-radius: 8px; border: 0; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); cursor: pointer; font-size: 18px; color: var(--text); }
.anbieter-leiste .entfernen { width: auto; padding: 0 10px; font-size: 13px; }
/* Pult der Demo */
.pult { position: fixed; left: 16px; bottom: 16px; z-index: 30; display: grid; gap: 8px; padding: 12px 14px; border-radius: 14px; background: rgba(43, 49, 49, .92); color: #e9eeee; backdrop-filter: blur(8px); max-width: calc(100vw - 32px); }
.pult .gruppe { display: flex; align-items: center; gap: 8px; }
.pult .gruppe > span { min-width: 64px; color: rgba(233, 238, 238, .6); }
.pult button { border: 1px solid rgba(233, 238, 238, .22); background: none; color: inherit; padding: 6px 9px; border-radius: 999px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #e9eeee; color: #1d2222; border-color: #e9eeee; }
.pult .werte { color: rgba(233, 238, 238, .55); max-width: 280px; }
.pult .werte b { color: #e9eeee; font-weight: 500; }
/* Variante „nur Liste“ */
[data-variante="kompakt"] .buehne { grid-template-columns: minmax(0, 760px); justify-content: center; }
[data-variante="kompakt"] .uebersicht { display: none; }
/* Zeiger */
.zeiger { position: fixed; left: 0; top: 0; width: 24px; height: 24px; margin: -12px 0 0 -12px; border-radius: 50%; border: 2px solid var(--marke); background: rgba(255, 199, 118, .55); pointer-events: none; z-index: 40; opacity: 0; transition: translate 600ms var(--kurve), opacity 200ms var(--kurve), scale var(--ui) var(--kurve); }
.vorfuehrung .zeiger.an { opacity: 1; }
.zeiger.klick { scale: .7; }
.reduziert *, .reduziert *::before, .reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (hover: none) {
.knopf:hover { background: var(--marke); }
.aktionen a, .aktionen button { min-height: 44px; padding-inline: 14px; }
}
@media (max-width: 900px) {
.buehne { grid-template-columns: minmax(0, 1fr); }
.uebersicht { position: relative; top: 0; order: 2; max-width: 520px; }
}
@media (max-width: 560px) {
.kopf .knopf .ziffern, .kopf .knopf .lang { display: none; }
.kopf .knopf .kurz { display: inline; }
.kopf .knopf { padding: 10px 12px; }
.suche { padding: 14px; border-radius: 16px; }
.suche-zeile > .knopf { width: 100%; }
.oder { display: none; }
.plz { flex: 1; }
.plz input { width: 100%; min-width: 0; flex: 1; }
.schalter { margin-left: 0; }
.ort { padding: 16px 16px 14px 56px; }
.nr { left: 14px; top: 16px; }
.ort h3 { font-size: 21px; }
.ort h3 small { display: inline-block; margin: 6px 0 0; vertical-align: 0; }
.aktionen { margin-left: 0; width: 100%; }
.aktionen > * { flex: 1; justify-content: center; }
.offen-status { min-width: 0; }
}
/* 320 px: Aktionen dürfen umbrechen, lange Adressen auch */
@media (max-width: 380px) {
.liste { grid-template-columns: minmax(0, 1fr); }
.ort { padding-left: 48px; }
.nr { left: 12px; }
.aktionen { flex-wrap: wrap; }
.aktionen a, .aktionen button { padding-inline: 12px; }
.adresse, .titel p { overflow-wrap: break-word; }
}
/* Vorschau 960 × 720 */
.vorschau body { overflow: hidden; }
.vorschau .titel p { display: none; }
.vorschau .buehne { padding-top: 18px; grid-template-columns: minmax(0, 1.2fr) minmax(300px, .8fr); gap: 24px; }
.vorschau .titel h1 { font-size: 32px; }
.vorschau .suche { margin-top: 14px; padding: 12px 14px; }
.vorschau .ort { padding: 14px 16px 12px 58px; }
.vorschau .ort h3 { font-size: 20px; }
.vorschau .zeile { margin-top: 8px; }
.vorschau .liste { gap: 8px; margin-top: 12px; }
.vorschau [data-variante="kompakt"] .buehne { grid-template-columns: minmax(0, 760px); }
.vorschau .zwei-klick p { display: none; }
.vorschau .zwei-klick { padding: 10px 14px; }
.vorschau .uebersicht svg { max-height: 520px; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } .plz input { border: 1px solid CanvasText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#start"><svg viewBox="0 0 30 30" aria-hidden="true"><circle cx="15" cy="15" r="14" fill="#005450"/><path d="M15 23V10M15 15c-3.6-.4-5.6-2.6-6-6 3.6.3 5.6 2.4 6 6Zm0 3c3.6-.4 5.6-2.6 6-6-3.6.3-5.6 2.4-6 6Z" fill="none" stroke="#f5f7f7" stroke-width="1.6" stroke-linejoin="round"/></svg><span><b>Lindgrün</b><span>Physiotherapie · Hildesheim und Umland</span></span></a>
<a class="knopf leicht" href="tel:+495121984710"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2"/></svg><span class="ziffern">05121 98 47 10</span></a>
<a class="knopf" href="#termin"><span class="lang">Termin vereinbaren</span><span class="kurz">Termin</span></a>
</header>
<div data-variante="liste" data-rahmen>
<main class="buehne">
<section aria-labelledby="titel">
<div class="titel">
<h1 class="serif" id="titel" data-titel>Sechs Praxen in und um Hildesheim.</h1>
<p>Finden Sie die Praxis, die am nächsten liegt und gerade geöffnet hat. Ihren Standort nutzen wir nur auf Ihren Wunsch und speichern ihn nicht.</p>
</div>
<div class="suche" role="search" aria-label="Praxis in der Nähe finden">
<div class="suche-zeile">
<button type="button" class="knopf" data-orten><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/><circle cx="12" cy="12" r="8"/></svg>Meinen Standort verwenden</button>
<span class="oder">oder</span>
<form class="plz" data-plz-form novalidate>
<label class="vh" for="plz">Postleitzahl</label>
<input id="plz" name="plz" inputmode="numeric" autocomplete="postal-code" maxlength="5" placeholder="PLZ" pattern="\d{5}" aria-describedby="status" data-plz>
<button type="submit">Suchen</button>
</form>
<label class="schalter"><input type="checkbox" role="switch" data-nur-offen> Nur jetzt geöffnet</label>
</div>
<div class="abfrage" data-abfrage hidden role="alertdialog" aria-labelledby="abfrage-text">
<small>Simulierte Abfrage des Browsers</small>
<span id="abfrage-text"><b>cecodesign.de</b> möchte Ihren Standort verwenden.</span>
<div><button type="button" data-abfrage="nein">Blockieren</button><button type="button" class="ja" data-abfrage="ja">Erlauben</button></div>
</div>
<p class="status" id="status" role="status" data-status>Sortiert nach Name. Entfernungen zeigen wir erst nach Ihrer Freigabe.</p>
</div>
<ol class="liste" data-liste aria-label="Standorte"></ol>
<div class="leer" data-leer hidden>
<h3>Gerade hat keine Praxis geöffnet.</h3>
<p data-leer-text></p>
<p><a href="#termin">Termin online anfragen</a> · <a href="tel:+495121984710">05121 98 47 10</a> (Anrufbeantworter, Rückruf am nächsten Werktag)</p>
</div>
</section>
<aside class="uebersicht" aria-label="Übersicht der Standorte" data-uebersicht>
<svg viewBox="0 0 400 580" data-karte role="img" aria-labelledby="karte-titel">
<title id="karte-titel">Gezeichnete Übersicht: sechs Praxen zwischen Sarstedt im Norden und Bad Salzdetfurth im Süden, vier davon in Hildesheim.</title>
<defs>
<pattern id="feld" width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(30)"><path d="M0 5h10" stroke="#e3e6e1" stroke-width="1"/></pattern>
</defs>
<g data-zoom>
<rect width="400" height="580" fill="#eceeea"/>
<rect width="400" height="580" fill="url(#feld)"/>
<!-- Wald -->
<path d="M120 330c20-30 60-36 70-10s-6 64-30 86-62 30-74 8 14-58 34-84Z" fill="#dfe5dc"/>
<path d="M270 420c26-8 54 6 58 30s-20 40-44 38-40-22-36-40 6-24 22-28Z" fill="#dfe5dc"/>
<path d="M330 170c18-6 40 4 42 24s-18 30-34 28-24-16-22-30 4-18 14-22Z" fill="#dfe5dc"/>
<!-- Orte -->
<path d="M188 238c26-20 74-24 104-6s30 66 12 96-62 44-96 36-50-30-52-62 6-46 32-64Z" fill="#e4e2dc"/>
<ellipse cx="94" cy="80" rx="34" ry="24" fill="#e4e2dc"/>
<ellipse cx="318" cy="516" rx="28" ry="20" fill="#e4e2dc"/>
<!-- Leine und Innerste -->
<path d="M24 580C34 500 44 430 60 360s14-130 18-190 6-110 10-170" fill="none" stroke="#bfd6d3" stroke-width="7" stroke-linecap="round"/>
<path d="M318 580c-6-30-4-60-22-90s-40-50-52-82-14-60-26-86-18-52-34-76-44-60-62-92-28-34-36-44" fill="none" stroke="#bfd6d3" stroke-width="4.5" stroke-linecap="round"/>
<!-- Straßen -->
<g fill="none" stroke-linecap="round">
<path d="M150 0C190 80 236 170 290 240s46 150 54 220 18 100 24 120" stroke="#d6d1c8" stroke-width="7"/>
<path d="M150 0C190 80 236 170 290 240s46 150 54 220 18 100 24 120" stroke="#fff" stroke-width="4"/>
<path d="M100 82c40 50 80 110 110 170M210 252c40 0 90-2 190-6M0 290c80 4 150 6 210 2M232 300c30 60 60 140 84 214" stroke="#fff" stroke-width="3"/>
</g>
<!-- Details, die erst die Karte des Anbieters zeigt -->
<g class="detail-lage" data-detail-lage></g>
<!-- Beschriftung -->
<g font-family="Google Sans Flex, sans-serif" font-size="11" fill="#596666" letter-spacing=".04em">
<text x="300" y="214">HILDESHEIM</text>
<text x="40" y="44">SARSTEDT</text>
<text x="262" y="560">BAD SALZDETFURTH</text>
<text x="40" y="410" font-style="italic" letter-spacing="0">Leine</text>
<text x="170" y="156" font-style="italic" letter-spacing="0">Innerste</text>
<text x="128" y="372" font-size="10" fill="#7b8a80">Hildesheimer Wald</text>
<text x="352" y="374" font-size="10" font-weight="600">A7</text>
<g font-size="10" fill="#7b8a80" letter-spacing="0"><circle cx="146" cy="179" r="2.5"/><text x="152" y="183">Giesen</text><circle cx="333" cy="147" r="2.5"/><text x="339" y="151">Harsum</text><circle cx="197" cy="428" r="2.5"/><text x="203" y="432">Diekholzen</text><circle cx="258" cy="40" r="2.5"/><text x="264" y="44">Algermissen</text></g>
</g>
<g class="ich" data-ich><circle class="welle" r="18" cx="0" cy="0"/><circle r="7" fill="#005450" stroke="#fff" stroke-width="3"/></g>
<g data-pins></g>
</g>
</svg>
<div class="titelzeile"><span data-karten-label>Übersicht, eigene Zeichnung</span><span data-ich-label hidden><b style="color:#005450">●</b> Ihr Standort</span></div>
<div class="anbieter-leiste"><button type="button" data-zoom-knopf="1" aria-label="Vergrößern">+</button><button type="button" data-zoom-knopf="-1" aria-label="Verkleinern">−</button></div>
<div class="zwei-klick" data-zwei-klick>
<p class="vorher"><b>Interaktive Karte</b> mit Straßen und Routen. Sie lädt erst nach Ihrem Klick vom Kartenanbieter; dabei geht Ihre IP-Adresse dorthin.</p>
<div class="reihe vorher"><button type="button" class="knopf" data-karte-laden>Karte laden</button><a href="#datenschutz">Datenschutz</a></div>
<div class="reihe geladen"><span class="label">Karte vom Anbieter geladen (in der Demo nachgebildet)</span><button type="button" class="knopf leicht" data-karte-entfernen>Karte entfernen</button></div>
</div>
</aside>
</main>
</div>
<div class="pult mono" aria-label="Steuerung der Demo">
<div class="gruppe" role="group" aria-label="Variante"><span>Variante</span><button type="button" data-v="liste" aria-pressed="true">Liste + Karte</button><button type="button" data-v="kompakt" aria-pressed="false">Nur Liste</button></div>
<div class="gruppe" role="group" aria-label="Uhrzeit"><span>Uhrzeit</span><button type="button" data-zeit="sim" aria-pressed="true">Di 17:40</button><button type="button" data-zeit="echt" aria-pressed="false">Jetzt</button></div>
<div class="gruppe" role="group" aria-label="Ortung"><span>Ortung</span><button type="button" data-ortung="sim" aria-pressed="true">Simuliert (Hbf)</button><button type="button" data-ortung="echt" aria-pressed="false">Echt</button></div>
<p class="werte">Kurve <b>cubic-bezier(0.2, 0, 0.38, 0.9)</b> · Zustände <b>150 ms</b> · Umordnen <b>250 ms</b> · Karte <b>400 ms</b></p>
</div>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const KM = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const KM0 = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 });
// ------------------------------------------------------------------
// Daten: Wochenzeiten in Minuten (0 = So … 6 = Sa), Sonderzeiten, Feiertage Niedersachsen
// Koordinaten sind Näherungen der Demo.
// ------------------------------------------------------------------
const h = (a, b) => [a, b];
const T = (s) => { const [x, y] = s.split(':').map(Number); return x * 60 + y; };
const zeiten = o => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v.map(([a, b]) => h(T(a), T(b)))]));
const STANDORTE = [
{ id: 'mitte', name: 'Innenstadt', zusatz: 'Hauptpraxis', adresse: 'Am Lindgarten 4, 31134 Hildesheim', tel: '05121 98 47 10', lat: 52.1520, lon: 9.9505,
woche: zeiten({ 1: [['7:30', '19:00']], 2: [['7:30', '19:00']], 3: [['7:30', '19:00']], 4: [['7:30', '19:00']], 5: [['7:30', '19:00']], 6: [['9:00', '13:00']] }), info: 'Aufzug, ebenerdiger Eingang' },
{ id: 'moritz', name: 'Moritzberg', adresse: 'Am Weinberghang 8, 31139 Hildesheim', tel: '05121 98 47 20', lat: 52.1475, lon: 9.9240,
woche: zeiten({ 1: [['8:00', '18:00']], 2: [['8:00', '18:00']], 3: [['8:00', '18:00']], 4: [['8:00', '18:00']], 5: [['8:00', '14:00']] }), info: 'Parkplätze im Hof' },
{ id: 'ochter', name: 'Ochtersum', adresse: 'Kiefernring 3, 31139 Hildesheim', tel: '05121 98 47 30', lat: 52.1295, lon: 9.9420,
woche: zeiten({ 1: [['7:00', '15:00']], 2: [['12:00', '20:00']], 3: [['7:00', '15:00']], 4: [['12:00', '20:00']], 5: [['7:00', '15:00']] }), info: 'Gerätetraining' },
{ id: 'drisp', name: 'Drispenstedt', adresse: 'Am Gleisbogen 12, 31135 Hildesheim', tel: '05121 98 47 40', lat: 52.1665, lon: 9.9800,
woche: zeiten({ 1: [['8:00', '17:30']], 2: [['8:00', '17:30']], 3: [['8:00', '17:30']], 4: [['8:00', '17:30']], 5: [['8:00', '17:30']] }), info: 'Kindertherapie' },
{ id: 'sarst', name: 'Sarstedt', adresse: 'Am Kirchplatz 6, 31157 Sarstedt', tel: '05066 70 81 50', lat: 52.2390, lon: 9.8600,
woche: zeiten({ 1: [['8:00', '18:00']], 2: [['8:00', '18:00']], 3: [['8:00', '18:00']], 4: [['8:00', '18:00']], 5: [['8:00', '13:00']] }), info: 'Lymphdrainage' },
{ id: 'salz', name: 'Bad Salzdetfurth', zusatz: 'Zweigpraxis', adresse: 'Solebadweg 1, 31162 Bad Salzdetfurth', tel: '05063 27 19 60', lat: 52.0620, lon: 10.0060,
woche: zeiten({ 2: [['8:00', '16:00']], 4: [['8:00', '16:00']] }), info: 'Hausbesuche im Umland' }
];
const KOMPAKT = ['mitte', 'moritz', 'sarst'];
const FEIERTAGE = { '2026-10-31': 'Reformationstag', '2026-12-25': '1. Weihnachtstag', '2026-12-26': '2. Weihnachtstag', '2027-01-01': 'Neujahr', '2027-03-26': 'Karfreitag', '2027-03-29': 'Ostermontag', '2027-05-01': 'Tag der Arbeit', '2027-05-06': 'Christi Himmelfahrt', '2027-05-17': 'Pfingstmontag', '2027-10-03': 'Tag der Deutschen Einheit', '2027-10-31': 'Reformationstag' };
const SONDER = { '2026-12-24': [h(480, 720)], '2026-12-31': [h(480, 720)] }; // Heiligabend, Silvester: 8 bis 12 Uhr
const TAGE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const TAGE_LANG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const PLZ = { '31134': [52.1530, 9.9480], '31135': [52.1640, 9.9850], '31137': [52.1640, 9.9300], '31139': [52.1380, 9.9300], '31141': [52.1350, 9.9750],
'31157': [52.2386, 9.8606], '31162': [52.0636, 10.0072], '31180': [52.1980, 9.8950], '31177': [52.2110, 10.0170], '31199': [52.0980, 9.9280],
'31171': [52.1620, 9.7850], '31174': [52.1880, 10.1040], '31188': [52.0840, 10.1630], '31079': [52.0510, 9.9030] };
const HBF = [52.1606, 9.9535];
// ------------------------------------------------------------------
// Uhrzeit: Europe/Berlin über Intl; in der Demo wahlweise Di 03.11.2026, 17:40
// ------------------------------------------------------------------
let zeitModus = 'sim';
const FMT = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', weekday: 'short' });
function jetzt() {
const d = zeitModus === 'sim' ? new Date(Date.UTC(2026, 10, 3, 16, 40)) : new Date();
const p = Object.fromEntries(FMT.formatToParts(d).map(x => [x.type, x.value]));
const iso = `${p.year}-${p.month}-${p.day}`;
const tag = new Date(Date.UTC(+p.year, +p.month - 1, +p.day)).getUTCDay();
return { iso, tag, min: +p.hour * 60 + +p.minute, datum: new Date(Date.UTC(+p.year, +p.month - 1, +p.day)) };
}
const isoPlus = (datum, n) => { const d = new Date(datum); d.setUTCDate(d.getUTCDate() + n); return { iso: d.toISOString().slice(0, 10), tag: d.getUTCDay() }; };
const fenster = (s, iso, tag) => FEIERTAGE[iso] ? [] : (SONDER[iso] || s.woche[tag] || []);
const uhr = m => `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
function status(s, n) {
const heute = fenster(s, n.iso, n.tag);
const jetztAuf = heute.find(([a, b]) => n.min >= a && n.min < b);
if (jetztAuf) {
const rest = jetztAuf[1] - n.min;
return rest <= 30 ? { art: 'bald', text: `Schließt in ${rest} Min. (${uhr(jetztAuf[1])})`, offen: true } : { art: 'auf', text: `Geöffnet bis ${uhr(jetztAuf[1])}`, offen: true };
}
const spaeter = heute.find(([a]) => a > n.min);
if (spaeter) return { art: 'zu', text: `Geschlossen · öffnet heute ${uhr(spaeter[0])}`, offen: false };
for (let i = 1; i <= 8; i++) {
const t = isoPlus(n.datum, i), f = fenster(s, t.iso, t.tag);
if (f.length) return { art: 'zu', text: `Geschlossen · öffnet ${i === 1 ? 'morgen' : TAGE[t.tag]} ${uhr(f[0][0])}`, offen: false };
}
return { art: 'zu', text: 'Geschlossen', offen: false };
}
// Entfernung: Haversine (Luftlinie)
function km([a, b], [c, d]) {
const R = 6371, r = x => x * Math.PI / 180;
const x = Math.sin(r(c - a) / 2) ** 2 + Math.cos(r(a)) * Math.cos(r(c)) * Math.sin(r(d - b) / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(x));
}
const kmText = d => d < 10 ? KM.format(d) : KM0.format(d);
// Projektion der gezeichneten Übersicht
const P = (lat, lon) => [(lon - 9.80) * 68.6 * 22.4, (52.27 - lat) * 111.2 * 22.4];
// ------------------------------------------------------------------
// Aufbau
// ------------------------------------------------------------------
const liste = $('[data-liste]'), statusZeile = $('[data-status]'), rahmen = $('[data-rahmen]');
const ICON = {
tel: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2"/></svg>',
route: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M3 11 21 3l-8 18-2-8-8-2Z"/></svg>',
pfeil: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>'
};
liste.innerHTML = STANDORTE.map(s => `
<li class="ort" data-id="${s.id}">
<span class="nr ziffern" aria-hidden="true"></span>
<article aria-labelledby="h-${s.id}">
<div class="ort-kopf">
<h3 id="h-${s.id}">${s.name}${s.zusatz ? ` <small>${s.zusatz}</small>` : ''}</h3>
<p class="entfernung ziffern" data-km></p>
<p class="adresse">${s.adresse} · ${s.info}</p>
</div>
<div class="zeile">
<p class="offen-status" data-offen><i aria-hidden="true"></i><span></span></p>
<div class="aktionen">
<a href="tel:+49${s.tel.replace(/^0/, '').replace(/\s/g, '')}">${ICON.tel}<span>Anrufen</span></a>
<a href="geo:${s.lat},${s.lon}?q=${encodeURIComponent(s.adresse)}" data-route>${ICON.route}<span>Route</span></a>
<button type="button" class="zeiten-knopf" aria-expanded="false" aria-controls="z-${s.id}">Zeiten ${ICON.pfeil}</button>
</div>
</div>
<div class="zeiten" id="z-${s.id}" inert><div><table class="ziffern"><caption class="vh">Öffnungszeiten ${s.name}</caption><tbody data-tabelle></tbody></table><p data-zeiten-hinweis></p></div></div>
</article>
</li>`).join('');
const zeilen = Object.fromEntries($$('.ort').map(li => [li.dataset.id, li]));
$('[data-pins]').innerHTML = STANDORTE.map(s => { const [x, y] = P(s.lat, s.lon); return `<g class="pin" data-pin="${s.id}" transform="translate(${x.toFixed(1)} ${y.toFixed(1)})" tabindex="0" role="button" aria-label="${s.name} in der Liste zeigen"><circle class="ring" r="13"/><text data-pin-nr></text></g>`; }).join('');
let v = 'liste', ich = null, ichText = '', reihenfolge = STANDORTE.map(s => s.id);
const sichtbar = () => STANDORTE.filter(s => v === 'liste' || KOMPAKT.includes(s.id));
function zeitenTabelle(s, n) {
const rows = [1, 2, 3, 4, 5, 6, 0].map(t => { const f = s.woche[t] || []; return `<tr class="${t === n.tag ? 'heute' : ''}"><td>${TAGE_LANG[t]}</td><td>${f.length ? f.map(([a, b]) => `${uhr(a)}–${uhr(b)}`).join(', ') : 'geschlossen'}</td></tr>`; }).join('');
zeilen[s.id].querySelector('[data-tabelle]').innerHTML = rows;
zeilen[s.id].querySelector('[data-zeiten-hinweis]').textContent = FEIERTAGE[n.iso] ? `Heute ${FEIERTAGE[n.iso]}: geschlossen.` : 'An Feiertagen in Niedersachsen geschlossen. 24. und 31.12. von 8 bis 12 Uhr.';
}
// Liste neu ordnen (FLIP, 250 ms), Status und Entfernungen setzen
function aktualisieren(animieren = true) {
const n = jetzt();
const nurOffen = $('[data-nur-offen]').checked;
const vorher = new Map($$('.ort:not([hidden])').map(li => [li, li.getBoundingClientRect().top]));
const liste2 = sichtbar().map(s => ({ s, st: status(s, n), d: ich ? km(ich, [s.lat, s.lon]) : null }));
liste2.sort((a, b) => ich ? a.d - b.d : STANDORTE.indexOf(a.s) - STANDORTE.indexOf(b.s));
let nr = 0, offenAnzahl = 0;
liste2.forEach(({ s, st, d }, i) => {
const li = zeilen[s.id];
liste.appendChild(li);
const zeigen = !nurOffen || st.offen;
li.hidden = !zeigen;
if (zeigen) nr++;
if (st.offen) offenAnzahl++;
li.querySelector('.nr').textContent = zeigen ? nr : '';
li.classList.toggle('naechster', !!ich && i === 0 && zeigen);
const o = li.querySelector('[data-offen]');
o.className = 'offen-status ' + st.art; o.querySelector('span').textContent = st.text;
const k = li.querySelector('[data-km]');
const neuKm = d == null ? '' : `<b>${kmText(d)} km</b><span>${i === 0 ? 'am nächsten' : 'Luftlinie'}</span>`;
if (k.innerHTML !== neuKm) { const war = !!k.innerHTML; k.innerHTML = neuKm; if (neuKm && !war && !reduziert()) { k.classList.remove('neu'); void k.offsetWidth; k.classList.add('neu'); } }
zeitenTabelle(s, n);
const pin = document.querySelector(`[data-pin="${s.id}"]`);
pin.querySelector('text').textContent = zeigen ? nr : '';
pin.classList.toggle('aus', !zeigen);
pin.classList.toggle('naechster', !!ich && i === 0 && zeigen);
});
STANDORTE.forEach(s => { if (!sichtbar().includes(s)) zeilen[s.id].hidden = true; });
$('[data-leer]').hidden = nr > 0;
if (!nr) {
const naechste = sichtbar().map(s => ({ s, st: status(s, n) }))[0];
$('[data-leer-text]').textContent = `Die nächste Öffnung: ${naechste.s.name}, ${naechste.st.text.replace('Geschlossen · ', '')}. Bei akuten Beschwerden außerhalb der Zeiten hilft der ärztliche Bereitschaftsdienst unter 116 117.`;
}
// FLIP
if (animieren && !reduziert()) {
$$('.ort:not([hidden])').forEach(li => {
if (!vorher.has(li)) { li.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 250, easing: 'cubic-bezier(0.2, 0, 0.38, 0.9)' }); return; }
const dy = vorher.get(li) - li.getBoundingClientRect().top;
if (Math.abs(dy) > 1) li.animate([{ transform: `translateY(${dy}px)` }, { transform: 'none' }], { duration: 250, easing: 'cubic-bezier(0.2, 0, 0.38, 0.9)' });
});
}
// Ich-Punkt
const ichEl = $('[data-ich]');
if (ich) { const [x, y] = P(ich[0], ich[1]); ichEl.setAttribute('transform', `translate(${Math.max(8, Math.min(392, x)).toFixed(1)} ${Math.max(8, Math.min(572, y)).toFixed(1)})`); }
ichEl.classList.toggle('an', !!ich);
$('[data-ich-label]').hidden = !ich;
return { nr, offenAnzahl };
}
function meldung(text, art = '', weg = false) {
statusZeile.className = 'status ' + art;
statusZeile.innerHTML = (art === 'ok' ? '<span class="punkt" aria-hidden="true"></span>' : '') + `<span>${text}</span>` + (weg ? '<button type="button" class="weg" data-weg>Entfernung entfernen</button>' : '');
}
function setzeOrt(koord, text) {
ich = koord; ichText = text;
const weit = km(koord, HBF);
aktualisieren();
meldung(`Sortiert nach Entfernung ${text}.${weit > 60 ? ` Sie sind etwa ${KM0.format(weit)} km von Hildesheim entfernt.` : ''}`, 'ok', true);
}
statusZeile.addEventListener('click', e => {
if (!e.target.closest('[data-weg]')) return;
ich = null; $('[data-plz]').value = '';
aktualisieren(); meldung('Sortiert nach Name. Ihr Standort ist verworfen.');
});
// Ortung: erst nach Klick. Simuliert (Hbf) oder echt per Geolocation API.
let ortung = 'sim';
const abfrage = $('[data-abfrage]');
$('[data-orten]').addEventListener('click', () => {
if (ortung === 'sim') { abfrage.hidden = false; if (!html.classList.contains('vorfuehrung')) abfrage.querySelector('.ja').focus({ preventScroll: true }); return; }
if (!('geolocation' in navigator)) { meldung('Ihr Browser kann den Standort nicht bestimmen. Bitte die PLZ eingeben.', 'fehler'); return; }
meldung('Standort wird ermittelt …');
navigator.geolocation.getCurrentPosition(
p => setzeOrt([p.coords.latitude, p.coords.longitude], 'von Ihrem Standort'),
err => meldung(err.code === 1 ? 'Standort nicht freigegeben. Kein Problem: Geben Sie einfach Ihre PLZ ein.' : 'Standort gerade nicht bestimmbar. Bitte die PLZ eingeben.', 'fehler'),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 });
});
abfrage.addEventListener('click', e => {
const b = e.target.closest('[data-abfrage]'); if (!b) return;
abfrage.hidden = true;
if (b.dataset.abfrage === 'nein') { meldung('Standort nicht freigegeben. Kein Problem: Geben Sie einfach Ihre PLZ ein.', 'fehler'); $('[data-plz]').focus(); return; }
meldung('Standort wird ermittelt …');
setTimeout(() => setzeOrt(HBF, 'von Ihrem Standort (Hauptbahnhof)'), reduziert() ? 0 : 500);
});
$('[data-plz-form]').addEventListener('submit', e => {
e.preventDefault();
const w = $('[data-plz]').value.trim();
if (!/^\d{5}$/.test(w)) { meldung('Bitte eine Postleitzahl mit fünf Ziffern eingeben, z. B. 31134.', 'fehler'); return; }
if (!PLZ[w]) { meldung(`Für ${w} kennen wir keine Lage. Bitte eine PLZ aus dem Raum Hildesheim (31…) oder den Standort verwenden.`, 'fehler'); return; }
setzeOrt(PLZ[w], `von PLZ ${w}`);
});
$('[data-nur-offen]').addEventListener('change', () => aktualisieren());
// Zeiten aufklappen
liste.addEventListener('click', e => {
const b = e.target.closest('.zeiten-knopf'); if (b) {
const li = b.closest('.ort'), auf = b.getAttribute('aria-expanded') !== 'true';
b.setAttribute('aria-expanded', String(auf)); li.classList.toggle('zeiten-auf', auf); li.querySelector('.zeiten').inert = !auf;
}
const r = e.target.closest('[data-route]');
if (r && html.classList.contains('vorfuehrung')) e.preventDefault();
});
// Liste ↔ Übersicht
function markiere(id, scroll) {
$$('.ort').forEach(li => li.classList.toggle('aktiv', li.dataset.id === id));
$$('[data-pin]').forEach(p => p.classList.toggle('aktiv', p.dataset.pin === id));
if (scroll && id) zeilen[id].scrollIntoView({ behavior: reduziert() ? 'auto' : 'smooth', block: 'nearest' });
}
$('[data-pins]').addEventListener('click', e => { const p = e.target.closest('[data-pin]'); if (p) markiere(p.dataset.pin, true); });
$('[data-pins]').addEventListener('keydown', e => { const p = e.target.closest('[data-pin]'); if (p && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); markiere(p.dataset.pin, true); zeilen[p.dataset.pin].querySelector('h3').scrollIntoView({ block: 'nearest' }); } });
liste.addEventListener('pointerover', e => { const li = e.target.closest('.ort'); if (li) markiere(li.dataset.id); });
liste.addEventListener('pointerleave', () => markiere(null));
liste.addEventListener('focusin', e => { const li = e.target.closest('.ort'); if (li) markiere(li.dataset.id); });
// Karte per 2-Klick (in der Demo nachgebildet; im Einsatz entsteht hier das iframe des Anbieters)
const uebersicht = $('[data-uebersicht]'), karte = $('[data-karte]');
let zoom = 1;
const still = () => html.classList.contains('vorfuehrung');
$('[data-karte-laden]').addEventListener('click', () => { uebersicht.classList.add('anbieter'); $('[data-karten-label]').textContent = 'Karte des Anbieters (nachgebildet)'; if (!still()) $('[data-karte-entfernen]').focus({ preventScroll: true }); });
$('[data-karte-entfernen]').addEventListener('click', () => { uebersicht.classList.remove('anbieter'); $('[data-karten-label]').textContent = 'Übersicht, eigene Zeichnung'; setzeZoom(1); if (!still()) $('[data-karte-laden]').focus({ preventScroll: true }); });
function setzeZoom(z) {
zoom = Math.max(1, Math.min(2.5, z));
const [cx, cy] = ich ? P(ich[0], ich[1]) : P(52.1520, 9.9505), w = 400 / zoom, hh = 580 / zoom;
const x = zoom === 1 ? 0 : Math.max(0, Math.min(400 - w, cx - w / 2)), y = zoom === 1 ? 0 : Math.max(0, Math.min(580 - hh, cy - hh / 2));
karte.setAttribute('viewBox', `${x.toFixed(1)} ${y.toFixed(1)} ${w.toFixed(1)} ${hh.toFixed(1)}`);
}
$$('[data-zoom-knopf]').forEach(b => b.addEventListener('click', () => setzeZoom(zoom + +b.dataset.zoomKnopf * .75)));
// Detail der nachgebildeten Anbieter-Karte: Straßennetz und Häuserblöcke, deterministisch erzeugt
(function details() {
let z = 7; const r = () => (z = (z * 16807) % 2147483647) / 2147483647;
const orte = [[244, 292, 64, 52, 70], [94, 80, 30, 20, 16], [318, 514, 24, 17, 12]];
let bloecke = '', strassen = '';
orte.forEach(([cx, cy, rx, ry, n]) => {
strassen += `<ellipse cx="${cx}" cy="${cy}" rx="${rx * .45}" ry="${ry * .45}"/>`;
for (let i = 0; i < 7; i++) { const a = i / 7 * Math.PI * 2 + .3, b = a + (r() - .5) * .5; strassen += `<path d="M${cx + Math.cos(a) * rx * .2} ${cy + Math.sin(a) * ry * .2} Q${cx + Math.cos(b) * rx * .6} ${cy + Math.sin(b) * ry * .6} ${cx + Math.cos(a) * rx * 1.05} ${cy + Math.sin(a) * ry * 1.05}"/>`; }
for (let i = 0; i < n; i++) {
const a = r() * Math.PI * 2, d = Math.sqrt(r()) * .92, x = cx + Math.cos(a) * rx * d, y = cy + Math.sin(a) * ry * d;
const w = 3 + r() * 6, hh = 2.5 + r() * 4;
bloecke += `<rect x="${(x - w / 2).toFixed(1)}" y="${(y - hh / 2).toFixed(1)}" width="${w.toFixed(1)}" height="${hh.toFixed(1)}" rx=".6" transform="rotate(${(r() * 60 - 30).toFixed(0)} ${x.toFixed(1)} ${y.toFixed(1)})"/>`;
}
});
$('[data-detail-lage]').innerHTML = `<g fill="#d8d4cc">${bloecke}</g><g fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round">${strassen}</g>`;
})();
// Uhr: mit echter Zeit jede Minute neu
setInterval(() => { if (zeitModus === 'echt') aktualisieren(false); }, 60000);
// ------------------------------------------------------------------
// Pult: Variante (?v=liste|kompakt), Uhrzeit, Ortung
// ------------------------------------------------------------------
const vKnoepfe = $$('[data-v]');
function variante(neu, url = true) {
v = neu === 'kompakt' ? 'kompakt' : 'liste';
rahmen.dataset.variante = v;
vKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
$('[data-titel]').textContent = v === 'liste' ? 'Sechs Praxen in und um Hildesheim.' : 'Drei Praxen, eine in Ihrer Nähe.';
aktualisieren(false);
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q.toString() + location.hash); }
}
vKnoepfe.forEach(b => b.addEventListener('click', () => { const lief = laeuft; neustart(); variante(b.dataset.v); if (lief) vorfuehren(); }));
$$('[data-zeit]').forEach(b => b.addEventListener('click', () => { zeitModus = b.dataset.zeit; $$('[data-zeit]').forEach(x => x.setAttribute('aria-pressed', String(x === b))); aktualisieren(); }));
$$('[data-ortung]').forEach(b => b.addEventListener('click', () => { ortung = b.dataset.ortung; $$('[data-ortung]').forEach(x => x.setAttribute('aria-pressed', String(x === b))); }));
variante(new URLSearchParams(location.search).get('v') || 'liste', false);
// ------------------------------------------------------------------
// Vorführung
// ------------------------------------------------------------------
const zeiger = $('[data-zeiger]');
let uhrwerk = [], laeuft = false;
const nach = (ms, f) => uhrwerk.push(setTimeout(f, ms));
function zeigeAuf(el, dx = .5, dy = .5) { if (!el) return; const r = el.getBoundingClientRect(); zeiger.style.translate = `${r.left + r.width * dx}px ${r.top + r.height * dy}px`; zeiger.classList.add('an'); }
function klick(sel, t, dx, dy) {
nach(t, () => zeigeAuf(typeof sel === 'function' ? sel() : $(sel), dx, dy));
nach(t + 560, () => { const el = typeof sel === 'function' ? sel() : $(sel); if (!el) return; zeiger.classList.add('klick'); if (el.click) el.click(); else el.dispatchEvent(new MouseEvent('click', { bubbles: true })); setTimeout(() => zeiger.classList.remove('klick'), 150); });
}
function tippen(t, el, text, ms = 110) { [...text].forEach((z, k) => nach(t + k * ms, () => { el.value += z; })); return t + text.length * ms; }
function grundzustand() {
ich = null; abfrage.hidden = true; $('[data-plz]').value = ''; $('[data-nur-offen]').checked = false;
uebersicht.classList.remove('anbieter'); $('[data-karten-label]').textContent = 'Übersicht, eigene Zeichnung'; setzeZoom(1); markiere(null);
$$('.ort').forEach(li => { li.classList.remove('zeiten-auf'); li.querySelector('.zeiten-knopf').setAttribute('aria-expanded', 'false'); li.querySelector('.zeiten').inert = true; });
meldung('Sortiert nach Name. Entfernungen zeigen wir erst nach Ihrer Freigabe.');
}
function runde() {
uhrwerk.forEach(clearTimeout); uhrwerk = [];
zeitModus = 'sim'; $$('[data-zeit]').forEach(x => x.setAttribute('aria-pressed', String(x.dataset.zeit === 'sim')));
const ortungVorher = ortung; ortung = 'sim';
grundzustand(); aktualisieren(false);
let t = 600;
klick('[data-orten]', t, .3); t += 1300;
klick('[data-abfrage="ja"]', t); t += 2000; // Liste ordnet sich nach Nähe
if (v === 'liste') { nach(t, () => { zeigeAuf($('[data-pin="drisp"]')); markiere('drisp'); }); t += 1400; }
nach(t, () => { zeigeAuf($('[data-plz]'), .4); $('[data-plz]').focus({ preventScroll: true }); }); t = tippen(t + 500, $('[data-plz]'), '31141');
nach(t + 200, () => markiere(null));
klick('.plz button', t + 200); t += 2200; // Ochtersum nach oben
klick('[data-nur-offen]', t, .5); t += 2000; // geschlossene fallen weg
if (v === 'liste') { klick('[data-karte-laden]', t, .5); t += 1800; nach(t, () => setzeZoom(1.75)); t += 1600; }
else { klick(() => $('.ort:not([hidden]) .zeiten-knopf'), t); t += 2200; }
nach(t, () => { zeiger.classList.remove('an'); ortung = ortungVorher; });
nach(t + 700, runde);
}
function vorfuehren() { pause(); html.classList.add('vorfuehrung'); laeuft = true; runde(); }
function pause() { uhrwerk.forEach(clearTimeout); uhrwerk = []; laeuft = false; }
function weiter() { if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; runde(); } }
function neustart() { pause(); html.classList.remove('vorfuehrung'); zeiger.classList.remove('an'); grundzustand(); aktualisieren(false); }
})();
</script>
</body>
</html>