Begehbare 3D-Visualisierung des Gartens Egglihaldenstrasse 9a
Geometrie massstaeblich aus dem Gartenplan-PDF (1:100) extrahiert: Belaege, Mauern, Pflanzenstandorte und 46 Hoehenkoten. Wohnhaus nach Gartenplan und den Fassadenplaenen von EHLE Architekten. - tools/: Pipeline PDF -> Szenenmodell (MuPDF, Node) - src/: three.js-Szene mit prozeduralen Texturen, Rundgang und Uebersicht - Docker/Traefik fuer garten.aicds.ch, Gitea-Workflow auf main Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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',
|
||||
travertin4060: 'Travertinplatten 40×60×3 cm',
|
||||
travertinstell: 'Travertinstellplatten',
|
||||
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;
|
||||
|
||||
$<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>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>${eibenCount} Eiben „Hilli“ 80/100 cm als Heckenpflanzung</li>
|
||||
<li>Ligusterhecke 170 cm hoch, 75 cm breit</li>
|
||||
<li>${site.walls.length} Mauern und Wände, ${site.stelen.length} Travertinstelen</li>
|
||||
<li>${site.lamps.length} Leuchten – sie schalten sich abends automatisch ein</li>
|
||||
<li>Gartenhaus 1,8 × 1,8 m, Spielhaus, Flammengrill mit Holzscheiterwand</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</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.';
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user