Files
DOSSIER-STANDALONE/src/ui/ContextImportDialog.tsx
T
karim 8fd8987b70 2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
2026-07-02 00:12:39 +02:00

307 lines
9.6 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.
// Standort-Import-Dialog (modal). Sucht einen Ort/Adresse (geo.admin
// SearchServer), lässt einen Radius wählen und importiert Gebäude-Grundrisse
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün) als Kontext-
// Geometrie ins Projekt.
//
// Muster wie ExportPdfDialog/ImportDialog: abgedunkeltes Overlay, Klick auf den
// Hintergrund + Esc schließen. Der eigentliche Netz-Abruf läuft hier (io/*); das
// Ergebnis wird über `onImport(objs)` nach oben gereicht (SitePanel legt es via
// Host in `project.context` ab). Bezeichner englisch, UI-Text/Kommentare deutsch
// (CONVENTIONS.md).
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { geocode, fetchBuildings } from "../io/swissTopo";
import type { GeocodeCandidate } from "../io/swissTopo";
import { fetchOsm } from "../io/osm";
import type { OsmSelection } from "../io/osm";
import { featuresToContextObjects } from "../io/geoContext";
import type { GeoFeature } from "../io/geoContext";
import type { GeoOrigin } from "../io/lv95";
import type { ContextObject } from "../model/types";
export interface ContextImportDialogProps {
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
onImport: (objs: ContextObject[]) => void;
onClose: () => void;
}
type Sources = {
swissBuildings: boolean;
osmBuildings: boolean;
osmRoads: boolean;
osmWater: boolean;
osmGreen: boolean;
};
const DEFAULT_SOURCES: Sources = {
swissBuildings: true,
osmBuildings: false,
osmRoads: true,
osmWater: true,
osmGreen: true,
};
export function ContextImportDialog({
onImport,
onClose,
}: ContextImportDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [query, setQuery] = useState("");
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
const [radius, setRadius] = useState(200);
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
const [searching, setSearching] = useState(false);
const [importing, setImporting] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const searchSeq = useRef(0);
const runSearch = async () => {
const q = query.trim();
if (!q) return;
setError(null);
setSearching(true);
const seq = ++searchSeq.current;
try {
const res = await geocode(q, 8);
if (seq !== searchSeq.current) return; // veraltet
setCandidates(res);
setSelected(res[0] ?? null);
if (res.length === 0) setStatus(t("ctxImport.noResults"));
else setStatus(null);
} catch {
if (seq === searchSeq.current) setError(t("ctxImport.searchError"));
} finally {
if (seq === searchSeq.current) setSearching(false);
}
};
const toggle = (key: keyof Sources) =>
setSources((s) => ({ ...s, [key]: !s[key] }));
const anySource =
sources.swissBuildings ||
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
sources.osmGreen;
const runImport = async () => {
if (!selected || !anySource) return;
setError(null);
setImporting(true);
setStatus(t("ctxImport.working"));
try {
const center = selected.lv95;
let origin: GeoOrigin | undefined;
const all: GeoFeature[] = [];
if (sources.swissBuildings) {
const r = await fetchBuildings(center, radius, origin);
origin = r.origin;
all.push(...r.features);
}
const osmSel: OsmSelection = {
buildings: sources.osmBuildings,
roads: sources.osmRoads,
water: sources.osmWater,
green: sources.osmGreen,
};
if (
osmSel.buildings ||
osmSel.roads ||
osmSel.water ||
osmSel.green
) {
const r = await fetchOsm(center, radius, osmSel, origin);
origin = r.origin;
all.push(...r.features);
}
if (all.length === 0) {
setError(t("ctxImport.empty"));
setStatus(null);
return;
}
const objs = featuresToContextObjects(all, selected.label);
onImport(objs);
setStatus(
t("ctxImport.imported", { count: all.length, sets: objs.length }),
);
onClose();
} catch {
setError(t("ctxImport.importError"));
setStatus(null);
} finally {
setImporting(false);
}
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("ctxImport.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("ctxImport.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("ctxImport.close")}
>
×
</button>
</header>
<div className="imp-body">
{/* Ortssuche */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.location")}</div>
<div className="cim-search-row">
<input
className="cim-input"
type="text"
value={query}
placeholder={t("ctxImport.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void runSearch();
}
}}
aria-label={t("ctxImport.location")}
/>
<button
className="imp-btn"
onClick={() => void runSearch()}
disabled={searching || !query.trim()}
>
{searching ? t("ctxImport.searching") : t("ctxImport.search")}
</button>
</div>
{candidates.length > 0 && (
<ul className="cim-results">
{candidates.map((c, i) => (
<li
key={`${c.label}-${i}`}
className={
"cim-result" + (c === selected ? " selected" : "")
}
onClick={() => setSelected(c)}
title={c.label}
>
{c.label}
</li>
))}
</ul>
)}
</section>
{/* Radius */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.radius")}</div>
<div className="cim-radius-row">
<input
className="cim-input cim-input-num"
type="number"
min={20}
max={2000}
step={10}
value={radius}
onChange={(e) =>
setRadius(
Math.max(20, Math.min(2000, Number(e.target.value) || 0)),
)
}
aria-label={t("ctxImport.radius")}
/>
<span className="cim-unit">m</span>
</div>
</section>
{/* Quellen */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.sources")}</div>
<div className="cim-checks">
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissBuildings}
onChange={() => toggle("swissBuildings")}
/>
{t("ctxImport.src.swissBuildings")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmBuildings}
onChange={() => toggle("osmBuildings")}
/>
{t("ctxImport.src.osmBuildings")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmRoads}
onChange={() => toggle("osmRoads")}
/>
{t("ctxImport.src.osmRoads")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmWater}
onChange={() => toggle("osmWater")}
/>
{t("ctxImport.src.osmWater")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmGreen}
onChange={() => toggle("osmGreen")}
/>
{t("ctxImport.src.osmGreen")}
</label>
</div>
</section>
{status && <div className="cim-status">{status}</div>}
{error && <div className="imp-warn">{error}</div>}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("ctxImport.cancel")}
</button>
<button
className="imp-btn primary"
onClick={() => void runImport()}
disabled={importing || !selected || !anySource}
>
{importing ? t("ctxImport.importing") : t("ctxImport.confirm")}
</button>
</footer>
</div>
</div>
);
}