Files
Gartenvisualisierung/src/ui/hud.ts
T
admGitAICDSandClaude Opus 5 fad23a34c3
Deploy App / deploy (push) Successful in 28s
Wege, Ebenen, Stufen und ein Sprung
Rueckmeldung Punkt fuer Punkt:

Weg rund ums Haus
- Der Ostweg ist jetzt durchgehend gepflastert von der Verbundstein-
  Anpassung (x 2.86) bis zur Grenze (x 4.45) - vorher 0.83 m breit mit
  Rasenstreifen daneben. Der Weg an der Suedgrenze reicht von der Grenze
  bis an die Nachbarmauer (z 4.49 bis 5.97).
- Winkelelemente Ost auf die Grenzlinie x 4.45; die Linie bei z 4.96 im
  Sueden war keine eigene Stuetzwand, das ist die Nachbarmauer selbst.

Hoehen und Gefaelle
- LEVELS koennen jetzt auch eine geneigte Ebene vorgeben. Die Wege sind
  Rampen: Ostweg rund 12%, Suedweg rund 3%, Feuerwehrzufahrt 13.6% aus
  den Plankoten -2.08 und -1.08. Der Architekturplan nennt an denselben
  Stellen "Steigung 9.9%" und "Steigung 13.0%".
- Ausserhalb der Zonen wird die Interpolation der naechsten Zone
  fortgesetzt statt ihres konstanten Ersatzwerts - die Feuerwehrzufahrt
  stand vorher als schwebende Platte ueber einer Gelaendekante.
- Gelaende oestlich der Grenze korrigiert: dort lag ein Huegel ueber dem
  Weg, obwohl der Plan -0.72 und -1.57 angibt.

Obere Ebene
- Der Rasen ist eben auf -0.02. Die Koten 0.09 bis 0.26 liegen schon im
  angehobenen Kiesbett des Sichtschutzes, nicht mehr im Rasen; ohne feste
  Vorgabe zog die Interpolation den Rasen huegelig hoch.

Stufen
- Beim gedeckten Sitzplatz stimmen jetzt beide schwebenden Platten: der
  Plan zeichnet zwei versetzte Rechtecke mit den Koten -0.02 und -0.17,
  bisher lagen sie auf -0.17 und -0.31 - die untere verschwand im Belag.
- Das Gartentor steht auf dem oberen Niveau (-0.13), von dort zwei Stufen
  hinunter auf den Hauptsitzplatz.
- Westtreppe ueber den ganzen Treppenblock (5 Stufen statt 4), die
  Gelaenderpfosten reichen bis ins Gelaende statt in der Luft zu enden.

Rundgang
- Treppen und schwebende Stufen sind begehbar: ihre Trittflaechen gehen in
  die Hoehenabfrage ein, die Stufen sind kein Hindernis mehr.
- Springen mit der Leertaste, rund 55 cm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 19:49:46 +02:00

225 lines
9.2 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: Klicken, dann <kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd>, <kbd>Shift</kbd> zum Rennen,
<kbd>Leertaste</kbd> zum Springen</li>
<li>Treppen und die schwebenden Stufen sind begehbar &ndash; hinauf geht es zu Fuss
oder mit einem Sprung</li>
<li>Am Handy: 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.';
},
};
}