Files
Gartenvisualisierung/src/ui/hud.ts
T
admGitAICDSandClaude Opus 5 1ac99b1660
Deploy App / deploy (push) Successful in 27s
Nordgrenze zum Nachbarn, Torbogen, einfacher Rundgang
Nordgrenze
- Sitzmauer und Travertinmauer waren vertauscht. Der Plan zeichnet aussen,
  auf der Terrasse der Nachbarn, die "Sitzmauer" (0.55 tief, rund 40 cm
  hoch: Terrasse -1.77, Krone -1.35) und direkt dahinter die
  "Travertinmauer", die jetzt bis auf das Niveau der Nachbarn hinunter-
  reicht - sie sehen Naturstein statt Beton.
- Terrasse der Nachbarn fest auf -1.77.
- Schotterwiese (Flaechen 4 und 5) eben: quer kein Hang mehr, laengs folgt
  sie der Feuerwehrzufahrt. Unter der Ligusterhecke fassen Winkelelemente
  den Garten der Nachbarn, so wie der Plan sie dort beschriftet. Wiesen mit
  fester Ebene werden jetzt plan gerendert statt dem Raster zu folgen.
- Eingang fuer die Nachbarn: Travertinstufe in der Luecke zwischen Hecke
  und Mauer ("Stufe" im Plan), darueber ein Torbogen aus Cortenstahl mit
  Kletterrose. Kein Tor.

Rundgang
- Klick auf den Boden laeuft dorthin, Ziehen dreht den Blick. Kein
  Mausfang mehr.
- Weiches Anfahren und Bremsen, gedaempftes Nachfuehren bei Stufen statt
  Fall-und-Aufprall, Rutschen an Hindernissen.
- Travertinmauern mit fester Krone sind begehbar; man steigt ueber die
  Sitzmauer vom Sitzplatz auf den Rasen, statt springen zu muessen.
- Gartentor oeffnet sich beim Naehertreten und schliesst hinter einem, E
  uebersteuert. Schwelle unter dem Tor schliesst die Luecke zum Podest.
- Blickrichtung blieb beim Wechsel Uebersicht/Rundgang nicht erhalten
  (Vorzeichenfehler in yawDegrees).
- mergeGeometries loest indizierte Geometrien vorher auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:50:44 +02:00

227 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const timeInput = $<HTMLInputElement>('time');
const timeOut = $<HTMLOutputElement>('time-out');
const seasonInput = $<HTMLInputElement>('season');
const seasonOut = $<HTMLOutputElement>('season-out');
const btnMode = $<HTMLButtonElement>('btn-mode');
const btnInfo = $<HTMLButtonElement>('btn-info');
const info = $<HTMLElement>('info');
const crosshair = $<HTMLElement>('crosshair');
const hint = $<HTMLElement>('hint');
// -- Standorte -----------------------------------------------------------
const nav = $<HTMLElement>('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<string, number>();
for (const s of site.surfaces) totals.set(s.type, (totals.get(s.type) ?? 0) + s.area);
const NAMES: Record<string, string> = {
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);
$<HTMLElement>('info-body').innerHTML = `
<p>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.</p>
<h3>Grundlagen</h3>
<dl>
<dt>Gartenplan</dt><dd>${site.meta.plan}</dd>
<dt>Architektur</dt><dd>${site.meta.architecture}</dd>
<dt>Standort</dt><dd>${site.meta.location.name}</dd>
<dt>Fixpunkt 0.00</dt><dd>${site.meta.location.groundMasl.toFixed(2)} m ü. M. (OK FB EG)</dd>
<dt>Höhen im Garten</dt><dd>${Math.min(...heights).toFixed(2)} bis ${Math.max(...heights).toFixed(2)} m</dd>
</dl>
<h3>Die beiden Ebenen</h3>
<p>Der Garten liegt auf zwei Niveaus. Der Hauptsitzplatz aus Travertinplatten
liegt auf <strong>&minus;0.33 m</strong>. Die Travertinsitzmauer überwindet
<strong>31 cm</strong>, danach geht es eben weiter auf <strong>&minus;0.02 m</strong>
mit Rasen, Kiesfeld und Flammengrill. Vom gedeckten Sitzplatz führen
<strong>zwei schwebende Travertinstufen</strong> hinunter.</p>
<h3>Beläge</h3>
<dl>${[...totals.entries()]
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `<dt>${NAMES[k] ?? k}</dt><dd>${v.toFixed(1)} m²</dd>`)
.join('')}</dl>
<h3>Bepflanzung und Ausstattung</h3>
<ul>
<li>${site.trees.length} Solitärgehölze und Bäume, inkl. Apfelbaum</li>
<li>Sichtschutz aus ${site.stelen.length} aufrechten Travertinstelen (50 × 6 cm)
und ${eibenCount} Eiben &bdquo;Hilli&ldquo; im Wechsel, alle im Dekorkiesbett &ndash;
${hSued} m gegen den Südnachbarn, ${hNord} m gegen Zollet</li>
<li>Ligusterhecke 170 cm hoch, 75 cm breit</li>
<li>${site.walls.length} Mauern und Wände</li>
<li>${site.lamps.length} Leuchten &ndash; sie schalten sich abends automatisch ein</li>
<li>${site.slabs.length} lose Travertinplatten 40 × 60 × 3 cm im Dekorkies</li>
<li>Rasenrandplatten als 6 cm schmale Stellplatten entlang der Rasenkante</li>
<li>Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit zwei Holzscheiterwänden
(je 150 × 200 cm)</li>
<li>Zwei schwebende Travertinstufen am Gartentor, Stabgittertor 100 cm</li>
<li>${site.linear.filter((l) => l.kind === 'treppe').length} Treppen mit Rampe
(Ost- und Westzugang), Geländer und Entwässerungsrinnen</li>
</ul>
<h3>Bedienung</h3>
<ul>
<li>Rundgang: auf den Boden klicken, dann l&auml;uft man dorthin. Mit der Maus ziehen
zum Umsehen.</li>
<li>Oder mit <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd> gehen, <kbd>Shift</kbd> zum Rennen,
<kbd>Leertaste</kbd> zum Springen</li>
<li>Treppen, Stufen und Sitzmauern sind begehbar. Das Gartentor &ouml;ffnet sich,
wenn man darauf zugeht; <kbd>E</kbd> &ouml;ffnet oder schliesst es von Hand.</li>
<li>Am Handy: auf den Boden tippen, links der Joystick, rechts wischen zum Umsehen</li>
<li>Übersicht: ziehen zum Drehen, Mausrad zum Zoomen</li>
<li>Die Sonne steht für Abtwil SG astronomisch korrekt</li>
</ul>
<p id="sun-readout"></p>
`;
const toggleInfo = () => {
info.hidden = !info.hidden;
btnInfo.setAttribute('aria-pressed', String(!info.hidden));
};
btnInfo.addEventListener('click', toggleInfo);
$<HTMLButtonElement>('info-close').addEventListener('click', toggleInfo);
// -- Joystick ------------------------------------------------------------
if (isTouch()) {
const pad = $<HTMLElement>('joystick');
const knob = $<HTMLElement>('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.';
},
};
}