Deploy App / deploy (push) Successful in 27s
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>
227 lines
9.4 KiB
TypeScript
227 lines
9.4 KiB
TypeScript
/**
|
||
* 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>−0.33 m</strong>. Die Travertinsitzmauer überwindet
|
||
<strong>31 cm</strong>, danach geht es eben weiter auf <strong>−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 „Hilli“ im Wechsel, alle im Dekorkiesbett –
|
||
${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 – 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ä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 öffnet sich,
|
||
wenn man darauf zugeht; <kbd>E</kbd> ö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.';
|
||
},
|
||
};
|
||
}
|