/** * Bedienoberflaeche: Standorte, Tageszeit, Modusumschaltung, Joystick, Infobox. */ import type { SiteData, StartView } from '../site'; import type { SunState } from '../scene/environment'; import { isTouch } from '../controls/walker'; export interface HudOptions { site: SiteData; onView(view: StartView): void; onMode(mode: 'walk' | 'overview'): void; onJoystick(x: number, y: number, active: boolean): void; } export interface Hud { time(): { hours: number; day: number }; setMode(mode: 'walk' | 'overview'): void; setPointerLocked(locked: boolean): void; setSunReadout(sun: SunState): void; } const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; function dayLabel(dayOfYear: number): string { const d = new Date(2026, 0, 1); d.setDate(dayOfYear); return `${d.getDate()}. ${MONTHS[d.getMonth()]}`; } function timeLabel(hours: number): string { const h = Math.floor(hours); const m = Math.round((hours - h) * 60); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } export function setupHud(opts: HudOptions): Hud { const { site } = opts; const $ = (id: string) => document.getElementById(id) as T; const timeInput = $('time'); const timeOut = $('time-out'); const seasonInput = $('season'); const seasonOut = $('season-out'); const btnMode = $('btn-mode'); const btnInfo = $('btn-info'); const info = $('info'); const crosshair = $('crosshair'); const hint = $('hint'); // -- Standorte ----------------------------------------------------------- const nav = $('views'); for (const v of site.startViews) { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = v.label; if (v.note) btn.title = v.note; btn.addEventListener('click', () => { for (const other of nav.querySelectorAll('button')) other.removeAttribute('aria-pressed'); btn.setAttribute('aria-pressed', 'true'); opts.onView(v); }); nav.append(btn); } nav.querySelector('button')?.setAttribute('aria-pressed', 'true'); // -- Tageszeit ----------------------------------------------------------- const syncTime = () => { timeOut.value = timeLabel(+timeInput.value); seasonOut.value = dayLabel(+seasonInput.value); }; timeInput.addEventListener('input', syncTime); seasonInput.addEventListener('input', syncTime); syncTime(); // -- Modus --------------------------------------------------------------- let mode: 'walk' | 'overview' = 'walk'; const setMode = (next: 'walk' | 'overview') => { mode = next; btnMode.textContent = mode === 'walk' ? 'Übersicht' : 'Rundgang'; btnMode.setAttribute('aria-pressed', String(mode === 'overview')); hint.style.display = mode === 'overview' ? 'none' : ''; opts.onMode(mode); }; btnMode.addEventListener('click', () => setMode(mode === 'walk' ? 'overview' : 'walk')); // -- Infobox ------------------------------------------------------------- const totals = new Map(); for (const s of site.surfaces) totals.set(s.type, (totals.get(s.type) ?? 0) + s.area); const NAMES: Record = { travertin80: 'Travertinplatten 80×80×3 cm', dekorkies: 'Dekorkies', travertinstell: 'Travertinstellplatten', randplatte: 'Rasenrandplatten', keramik: 'Keramikplatten 60×60×2 cm', verbundstein: 'Verbundsteine (Nachbar)', tegula: 'Tegula-Pflastersteine grau', rasen: 'Rasen', schotterwiese: 'Schotterwiese', beet: 'Pflanzbeete', spielsand: 'Spielsand', }; const heights = site.terrain.spots.filter((s) => s.zone === 'garten').map((s) => s.y); const eibenCount = site.plants.filter((p) => p.type === 'eibe').length; const hSued = Math.max(0, ...site.stelen.filter((s) => s.side === 'sued').map((s) => s.height)).toFixed(2); const hNord = Math.min(9, ...site.stelen.filter((s) => s.side !== 'sued').map((s) => s.height)).toFixed(2); $('info-body').innerHTML = `

Alle Flächen, Mauern und Pflanzstandorte sind massstäblich aus dem Gartenplan übernommen. Die Vektorgeometrie wurde direkt aus dem Plan-PDF (1:100) extrahiert, das Gelände aus den ${site.terrain.spots.length} Höhenkoten interpoliert.

Grundlagen

Gartenplan
${site.meta.plan}
Architektur
${site.meta.architecture}
Standort
${site.meta.location.name}
Fixpunkt 0.00
${site.meta.location.groundMasl.toFixed(2)} m ü. M. (OK FB EG)
Höhen im Garten
${Math.min(...heights).toFixed(2)} bis ${Math.max(...heights).toFixed(2)} m

Die beiden Ebenen

Der Garten liegt auf zwei Niveaus. Der Hauptsitzplatz aus Travertinplatten liegt auf −0.33 m. Die Travertinsitzmauer überwindet 31 cm, danach geht es eben weiter auf −0.02 m mit Rasen, Kiesfeld und Flammengrill. Vom gedeckten Sitzplatz führen zwei schwebende Travertinstufen hinunter.

Beläge

${[...totals.entries()] .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `
${NAMES[k] ?? k}
${v.toFixed(1)} m²
`) .join('')}

Bepflanzung und Ausstattung

  • ${site.trees.length} Solitärgehölze und Bäume, inkl. Apfelbaum
  • Sichtschutz aus ${site.stelen.length} aufrechten Travertinstelen (50 × 6 cm) und ${eibenCount} Eiben „Hilli“ im Wechsel, alle im Dekorkiesbett – ${hSued} m gegen den Südnachbarn, ${hNord} m gegen Zollet
  • Ligusterhecke 170 cm hoch, 75 cm breit
  • ${site.walls.length} Mauern und Wände
  • ${site.lamps.length} Leuchten – sie schalten sich abends automatisch ein
  • ${site.slabs.length} lose Travertinplatten 40 × 60 × 3 cm im Dekorkies
  • Rasenrandplatten als 6 cm schmale Stellplatten entlang der Rasenkante
  • Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit zwei Holzscheiterwänden (je 150 × 200 cm)
  • Zwei schwebende Travertinstufen am Gartentor, Stabgittertor 100 cm
  • ${site.linear.filter((l) => l.kind === 'treppe').length} Treppen mit Rampe (Ost- und Westzugang), Geländer und Entwässerungsrinnen

Bedienung

  • Rundgang: auf den Boden klicken, dann läuft man dorthin. Mit der Maus ziehen zum Umsehen.
  • Oder mit W A S D gehen, Shift zum Rennen, Leertaste zum Springen
  • Treppen, Stufen und Sitzmauern sind begehbar. Das Gartentor öffnet sich, wenn man darauf zugeht; E öffnet oder schliesst es von Hand.
  • Am Handy: auf den Boden tippen, links der Joystick, rechts wischen zum Umsehen
  • Übersicht: ziehen zum Drehen, Mausrad zum Zoomen
  • Die Sonne steht für Abtwil SG astronomisch korrekt

`; const toggleInfo = () => { info.hidden = !info.hidden; btnInfo.setAttribute('aria-pressed', String(!info.hidden)); }; btnInfo.addEventListener('click', toggleInfo); $('info-close').addEventListener('click', toggleInfo); // -- Joystick ------------------------------------------------------------ if (isTouch()) { const pad = $('joystick'); const knob = $('joystick-knob'); let id: number | null = null; const radius = 36; const reset = () => { knob.style.transform = ''; opts.onJoystick(0, 0, false); id = null; }; pad.addEventListener('touchstart', (e) => { e.preventDefault(); id = e.changedTouches[0].identifier; }, { passive: false }); pad.addEventListener('touchmove', (e) => { e.preventDefault(); for (const t of Array.from(e.changedTouches)) { if (t.identifier !== id) continue; const r = pad.getBoundingClientRect(); let dx = t.clientX - (r.left + r.width / 2); let dy = t.clientY - (r.top + r.height / 2); const len = Math.hypot(dx, dy); if (len > radius) { dx = (dx / len) * radius; dy = (dy / len) * radius; } knob.style.transform = `translate(${dx}px, ${dy}px)`; opts.onJoystick(dx / radius, dy / radius, true); } }, { passive: false }); pad.addEventListener('touchend', reset, { passive: true }); pad.addEventListener('touchcancel', reset, { passive: true }); } window.setTimeout(() => hint.classList.add('fade'), 9000); return { time: () => ({ hours: Math.round(+timeInput.value * 4) / 4, day: +seasonInput.value, }), setMode, setPointerLocked(locked) { crosshair.classList.toggle('on', locked); if (locked) hint.classList.add('fade'); }, setSunReadout(sun) { const el = document.getElementById('sun-readout'); if (!el) return; el.textContent = sun.altitude > 0 ? `Sonnenstand: ${sun.altitude.toFixed(0)}° über dem Horizont, Azimut ${sun.azimuth.toFixed(0)}°.` : 'Die Sonne steht unter dem Horizont – die Gartenbeleuchtung ist eingeschaltet.'; }, }; }