Deploy App / deploy (push) Successful in 28s
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>
225 lines
9.2 KiB
TypeScript
225 lines
9.2 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: 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 – 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.';
|
||
},
|
||
};
|
||
}
|