// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Karim Gabriele Varano
import { useState, useEffect, useRef } from 'react'
import Icon from './components/Icon'
import { BarToggle, BAR_H } from './components/BarControls'
import { onMessage, notifyReady } from './lib/rhinoBridge'
function send(type, payload = {}) {
if (!window.RHINO_MODE) { console.log('[OSM] →', type, payload); return }
document.title = 'RHINOMSG::' + JSON.stringify({ type, payload })
}
const pillInput = {
height: BAR_H,
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 999,
color: 'var(--text-primary)',
fontSize: 11, fontFamily: 'var(--font)',
padding: '0 10px',
outline: 'none',
boxSizing: 'border-box',
}
function Field({ label, hint, children }) {
return (
{label &&
{label}}
{children}
{hint && (
{hint}
)}
)
}
function SectionLabel({ children }) {
return (
{children}
)
}
function Radio({ value, options, onChange }) {
return (
{options.map(o => (
onChange(o.value)} />
))}
)
}
// OSM-Kategorien — Keys matchen das Backend (osm.py CATEGORIES).
const CATEGORIES = [
{ key: 'streets', label: 'Strassen', icon: 'route',
hint: 'Autobahn/Hauptstrasse/Quartierstrasse → Polylinien' },
{ key: 'buildings', label: 'Gebäudeumrisse', icon: 'apartment',
hint: 'building=* Umrisse als geschlossene Polylinien' },
{ key: 'water', label: 'Wasser (Flächen)', icon: 'water',
hint: 'natural=water (Seen, Teiche)' },
{ key: 'waterways', label: 'Wasserläufe', icon: 'waves',
hint: 'waterway=river/stream/canal' },
{ key: 'parks', label: 'Parks', icon: 'park',
hint: 'leisure=park/garden' },
{ key: 'forest', label: 'Wald & Grün', icon: 'forest',
hint: 'landuse=forest/grass/meadow' },
{ key: 'footpaths', label: 'Fuss-/Radwege', icon: 'directions_walk',
hint: 'highway=footway/path/track/cycleway' },
]
export default function OsmApp() {
// Standort
const [searchText, setSearchText] = useState('')
const [center, setCenter] = useState(null)
const [searching, setSearching] = useState(false)
// Optionen
const [radius, setRadius] = useState(200)
const [selected, setSelected] = useState({
streets: true, buildings: true, waterways: true,
parks: true, forest: true,
water: false, footpaths: false,
})
const [shift, setShift] = useState(true)
const [autoZoom, setAutoZoom] = useState(true)
const [replaceExisting, setReplaceExisting] = useState(true)
// Live-Log
const [logs, setLogs] = useState([])
const [running, setRunning] = useState(false)
const [done, setDone] = useState(false)
const logRef = useRef(null)
useEffect(() => {
onMessage('GEOCODE_RESULT', ({ result }) => {
setSearching(false)
if (result && result.e != null && result.n != null) {
setCenter({ e: result.e, n: result.n, label: result.label || searchText })
} else {
setCenter(null)
addLog('Keine Adresse gefunden')
}
})
onMessage('OSM_LOG', ({ msg }) => addLog(msg))
onMessage('IMPORT_DONE', ({ count }) => {
setRunning(false); setDone(true)
addLog(`✓ Fertig — ${count} OSM-Objekt(e) importiert`)
})
notifyReady()
const blockContext = (ev) => ev.preventDefault()
document.addEventListener('contextmenu', blockContext)
return () => document.removeEventListener('contextmenu', blockContext)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [logs])
const addLog = (m) => setLogs(l => [...l, m])
const handleSearch = () => {
const t = searchText.trim()
if (!t) return
setSearching(true); setCenter(null)
addLog(`Suche '${t}'...`)
send('GEOCODE', { text: t })
}
const handleManualCoords = (eRaw, nRaw) => {
const e = parseFloat(eRaw), n = parseFloat(nRaw)
if (e > 2000000 && n > 1000000) {
setCenter({ e, n, label: `LV95 manuell` })
} else {
setCenter(null)
}
}
const handleImport = () => {
if (!center) { addLog('Bitte zuerst einen Standort wählen'); return }
const cats = Object.entries(selected).filter(([, v]) => v).map(([k]) => k)
if (cats.length === 0) { addLog('Mindestens eine Kategorie auswählen'); return }
setLogs([]); setRunning(true); setDone(false)
send('RUN_OSM_IMPORT', {
centerE: center.e, centerN: center.n,
radius: Number(radius),
categories: cats,
shiftToOrigin: shift,
autoZoom,
replaceExisting,
})
}
const toggleCat = (key) => {
setSelected(s => ({ ...s, [key]: !s[key] }))
}
return (
Standort
setSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch() }}
placeholder="Adresse oder Ortsname"
style={{ ...pillInput, flex: 1 }}
/>
handleManualCoords(e.target.value, center?.n || '')}
style={{ ...pillInput, width: 110, fontFamily: 'DM Mono, monospace' }} />
/
handleManualCoords(center?.e || '', e.target.value)}
style={{ ...pillInput, width: 110, fontFamily: 'DM Mono, monospace' }} />
{center && (
{center.label}
E {Math.round(center.e)} · N {Math.round(center.n)}
)}
Bereich
Kategorien
{CATEGORIES.map(cat => (
))}
Positionierung
setShift(v === 'origin')} />
Status
{logs.length === 0
?
Bereit
: logs.map((l, i) =>
{l}
)}
{running &&
Läuft…
}
Quelle: Overpass-API · © OpenStreetMap-Mitwirkende (ODbL)
send('CANCEL')} />
)
}