Files
DOSSIER-STANDALONE/src/ui/ContextImportDialog.tsx
T
karim 1d939a939a Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
2026-07-09 00:57:29 +02:00

370 lines
12 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/Parkplätze/
// Bahn/Wald) 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, fetchTerrain } 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 { terrainMeshFromGrid } from "../model/terrain";
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;
swissTerrain: boolean;
osmBuildings: boolean;
osmRoads: boolean;
osmWater: boolean;
osmGreen: boolean;
osmParking: boolean;
osmRailway: boolean;
osmForest: boolean;
};
const DEFAULT_SOURCES: Sources = {
swissBuildings: true,
swissTerrain: true,
osmBuildings: false,
osmRoads: true,
osmWater: true,
osmGreen: true,
osmParking: false,
osmRailway: false,
osmForest: false,
};
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.swissTerrain ||
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
sources.osmGreen ||
sources.osmParking ||
sources.osmRailway ||
sources.osmForest;
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,
parking: sources.osmParking,
railway: sources.osmRailway,
forest: sources.osmForest,
};
if (
osmSel.buildings ||
osmSel.roads ||
osmSel.water ||
osmSel.green ||
osmSel.parking ||
osmSel.railway ||
osmSel.forest
) {
const r = await fetchOsm(center, radius, osmSel, origin);
origin = r.origin;
all.push(...r.features);
}
// Gelände-Höhenraster (swissALTI3D via geo.admin-Profil) → Terrain-Mesh.
let terrain: ContextObject | null = null;
if (sources.swissTerrain) {
const r = await fetchTerrain(center, radius, origin);
origin = r.origin;
if (r.grid) {
const mesh = terrainMeshFromGrid(r.grid);
if (mesh.positions.length >= 9) terrain = mesh;
}
}
if (all.length === 0 && !terrain) {
setError(t("ctxImport.empty"));
setStatus(null);
return;
}
const objs = featuresToContextObjects(all, selected.label);
if (terrain) objs.push(terrain);
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.swissTerrain}
onChange={() => toggle("swissTerrain")}
/>
{t("ctxImport.src.swissTerrain")}
</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>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmParking}
onChange={() => toggle("osmParking")}
/>
{t("ctxImport.src.osmParking")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmRailway}
onChange={() => toggle("osmRailway")}
/>
{t("ctxImport.src.osmRailway")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmForest}
onChange={() => toggle("osmForest")}
/>
{t("ctxImport.src.osmForest")}
</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>
);
}