diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c5a96907..3e8bc5e19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,6 +69,134 @@ jobs: echo "########## $f"; tail -80 "$f" done || true + # ----------------------------------------------------------------------- + # Web UI (WebUI/). Build the JS/Python components against the freshly + # built kernel, then bring the whole stack up (SecondoMonitor + FastAPI + # bridge) and confirm SECONDO answers a real query through the REST + # interface. The headless-browser end-to-end tests (WebUI/frontend/e2e) + # are intentionally NOT run here. + # ----------------------------------------------------------------------- + - name: Build the SECONDO client library + # The bridge's native module links libsecondo.a, which the top-level + # make does not build. + run: make -C apis/api_cpp/cs + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: WebUI/frontend/package-lock.json + + - name: Build the WebUI frontend + working-directory: WebUI/frontend + run: | + npm ci + npm run build + + - name: Build and test the WebUI backend + # Builds the pybind11 module over libsecondo.a and runs the backend + # unit tests (which fake the SECONDO connection, so no monitor needed). + working-directory: WebUI/backend + run: | + python3 -m venv .venv + . .venv/bin/activate + pip install -r requirements.txt + make -C native + python -m pytest -q + + - name: Restore the berlintest database + # A dedicated database home for the WebUI monitor, shared with the + # following steps via $GITHUB_ENV. + run: | + echo "${GITHUB_WORKSPACE}/bin" >> "$GITHUB_PATH" + echo "SECONDO_CONFIG=${GITHUB_WORKSPACE}/bin/SecondoConfig.ini" >> "$GITHUB_ENV" + echo "SECONDO_PARAM_SecondoHome=${RUNNER_TEMP}/webui-dbs" >> "$GITHUB_ENV" + export PATH="${GITHUB_WORKSPACE}/bin:${PATH}" + export SECONDO_CONFIG="${GITHUB_WORKSPACE}/bin/SecondoConfig.ini" + export SECONDO_PARAM_SecondoHome="${RUNNER_TEMP}/webui-dbs" + mkdir -p "$SECONDO_PARAM_SecondoHome" + cd bin + printf 'create database berlintest;\nrestore database berlintest from berlintest;\nclose database;\nquit;\n' \ + | ./SecondoTTYBDB + + - name: Start the SECONDO monitor + working-directory: bin + run: | + nohup ./SecondoMonitor -s > "${RUNNER_TEMP}/secondo-monitor.log" 2>&1 & + echo "SECONDO_MONITOR_PID=$!" >> "$GITHUB_ENV" + for i in $(seq 1 60); do + if (exec 3<>/dev/tcp/127.0.0.1/1234) 2>/dev/null; then + exec 3>&- 3<&-; echo "Monitor is listening on port 1234"; exit 0 + fi + sleep 1 + done + echo "SECONDO monitor did not come up"; cat "${RUNNER_TEMP}/secondo-monitor.log"; exit 1 + + - name: Start the WebUI backend bridge + working-directory: WebUI/backend + run: | + . .venv/bin/activate + nohup uvicorn app.main:app --host 127.0.0.1 --port 8000 \ + > "${RUNNER_TEMP}/webui-backend.log" 2>&1 & + echo "WEBUI_BACKEND_PID=$!" >> "$GITHUB_ENV" + + - name: Query SECONDO through the REST interface + run: | + # Wait for the bridge to accept requests. + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:8000/api/health > /dev/null; then break; fi + if [ "$i" = 60 ]; then + echo "Backend did not come up"; cat "${RUNNER_TEMP}/webui-backend.log"; exit 1 + fi + sleep 1 + done + + # The first SECONDO command races the monitor's per-connection + # server fork, so retry until the catalog lists BERLINTEST. + echo "== /api/databases ==" + dbs="" + for i in $(seq 1 30); do + dbs=$(curl -sf http://127.0.0.1:8000/api/databases || true) + if echo "$dbs" | grep -q BERLINTEST; then break; fi + if [ "$i" = 30 ]; then + echo "BERLINTEST not listed (last response: ${dbs:-})"; exit 1 + fi + sleep 1 + done + echo "$dbs" + + # Open the database and run a real spatial query, keeping the session + # cookie so both land on the same SECONDO connection. + jar="${RUNNER_TEMP}/webui-cookies.txt" + curl -sf -c "$jar" -b "$jar" -X POST http://127.0.0.1:8000/api/query \ + -H 'Content-Type: application/json' \ + -d '{"command":"open database berlintest"}' > /dev/null + + echo "== query mehringdamm ==" + res=$(curl -sf -c "$jar" -b "$jar" -X POST http://127.0.0.1:8000/api/query \ + -H 'Content-Type: application/json' \ + -d '{"command":"query mehringdamm"}') + echo "$res" + # The point value and its GeoJSON conversion must both be present. + echo "$res" | grep -q '(point (9396.0 9871.0))' + echo "$res" | grep -q '"geojson"' + echo "SECONDO answered a query through the REST bridge." + + - name: Stop the WebUI stack + if: always() + run: | + kill "${WEBUI_BACKEND_PID:-}" 2>/dev/null || true + kill "${SECONDO_MONITOR_PID:-}" 2>/dev/null || true + + - name: Show WebUI logs on failure + if: failure() + run: | + echo "########## SECONDO monitor" + cat "${RUNNER_TEMP}/secondo-monitor.log" 2>/dev/null || true + echo "########## WebUI backend" + cat "${RUNNER_TEMP}/webui-backend.log" 2>/dev/null || true + build-macos: # Builds the C++ kernel + TTY, the Java GUI, the embedded-Prolog optimizer # engine and the JPL optimizer server (OptServer), then runs the full test diff --git a/WebUI/.gitignore b/WebUI/.gitignore new file mode 100644 index 000000000..361d19c1f --- /dev/null +++ b/WebUI/.gitignore @@ -0,0 +1,18 @@ +# Python +backend/.venv/ +__pycache__/ +*.pyc +.pytest_cache/ + +# Native build artifacts +backend/native/*.so +backend/native/*.o + +# Node / Vite +frontend/node_modules/ +frontend/dist/ +frontend/e2e/out/ +frontend/.vite/ + +# Local logs +*.log diff --git a/WebUI/README.md b/WebUI/README.md new file mode 100644 index 000000000..a551d9e09 --- /dev/null +++ b/WebUI/README.md @@ -0,0 +1,304 @@ +# SECONDO Web UI + +A modern web replacement for the Java Swing **HoeseViewer**: send SECONDO +commands, view results as text and (from Milestone 2 on) on a map, and animate +moving objects on a timeline. + +See the design/plan write-up for the full stack rationale and milestone +breakdown. This README covers running what exists today. + +## Architecture + +``` +Browser (React + Vite) + │ HTTP /api/* + ▼ +FastAPI bridge (backend/) + │ in-process call + ▼ +secondo_native (pybind11 wrapper, backend/native/) + │ links libsecondo.a + ▼ +SECONDO C++ client (apis/api_cpp/cs) ──TCP 1234──► SecondoMonitor +``` + +The bridge is mandatory: a browser cannot speak SECONDO's raw TCP nested-list +protocol. Crucially, the **wire protocol is never reimplemented** — the pybind11 +module wraps the in-tree, prebuilt reference client (`apis/api_cpp/cs`, +`libsecondo.a`). Only nested-list *text* crosses into Python, where it is turned +into GeoJSON (Milestone 2+). + +## Status + +**Milestone 1 (pipeline spike) — done & verified end-to-end.** +Connect · `list databases` · `open database` · run a command · get the result +nested list as text, with SECONDO errors surfaced to the UI. A command console +frontend drives it. + +**Milestone 2 (static spatial rendering) — done & verified end-to-end.** +The bridge parses the result nested list and converts `point` / `points` / +`line` / `region` / `rect` (and relations carrying such attributes) into +GeoJSON (`app/nlparser.py`, `app/geojson.py`). The frontend renders it with +**deck.gl** in a Cartesian orthographic view, auto-fit to the data bounds, with +hover tooltips for relation attributes. Verified in a headless browser +(`npm run e2e`) drawing point, line and region from berlintest onto the WebGL +canvas. + +**Milestone 3 (moving objects + timeline) — done & verified end-to-end.** +The bridge converts `mpoint` (and relations of `mpoint`, e.g. `Trains`) into +deck.gl **TripsLayer** data — per continuous run a `path` of waypoints and +matching `timestamps`, plus a `timeDomain` (`app/temporal.py`). Instant strings +(`2003-11-20-06:03:52.685`) are parsed to POSIX seconds. The frontend animates a +single `currentTime` clock via `requestAnimationFrame` (`useAnimator`, replacing +the HoeseViewer's Swing timer) with a play/pause/scrub/speed timeline; the +moving object's position is interpolated on the GPU. Verified in a headless +browser (`npm run e2e -- animation`): the trail head moves >500px along the +Trains trajectories between t=20% and t=80% of the domain. + +**Milestone 4 (layers, styling, selection) — done & verified end-to-end.** +Query results now accumulate as **layers** instead of replacing one another +(`layers/useLayers.ts`). A **layers panel** (`layers/LayersPanel.tsx`) toggles +visibility, reorders draw order, removes layers, and edits each layer's style +(color, opacity, point radius, line width, region fill) via a category editor. +The map renders N styled layers, auto-fits to their combined bounds, animates +all visible moving-object layers on one shared clock, and supports +**click-to-select** a feature with a details panel of its attributes. Verified +in a headless browser (`npm run e2e -- layers`): two layers listed, recolor +updates the swatch, hiding a layer changes what's drawn, reordering swaps the +top layer, and clicking a region shows its `Name`. + +**Milestone 5 (catalog, geographic basemap, export) — done & verified end-to-end.** +- **DB & object browser** (`catalog/Catalog.tsx`, backend `/api/objects` + + `app/catalog.py`): pick a database to open, then click a typed object to query + it; objects show a kind hint (spatial ◆ / temporal ◷) and filter box. +- **Geographic MapLibre + OSM basemap** (`map/MapView.tsx`): when a result's + coordinates fall within lon/lat ranges the map switches from the Cartesian + orthographic view to a geographic `MapView` with a raster **OpenStreetMap** + basemap (no API key), deck.gl layers reprojected to `LNGLAT`; otherwise it + stays Cartesian. Also **fixed `sline`** decoding (its value wraps the segment + list with a direction flag). +- **Export**: the layers panel exports all visible layers (static features + + moving-object trips as LineStrings) as a downloadable `.geojson` + (`layers/exportGeoJSON.ts`). +- Verified in a headless browser (`npm run e2e -- catalog`): catalog lists + databases, opening SYMTRAJSMALL lists 27 objects, clicking `EdgesExtDo` + (lon/lat) activates geographic mode and loads real OSM tiles with the road + segments aligned to the Hagen street grid, and export yields a valid + FeatureCollection. + +**Milestone 6 (moving regions + value plots) — done & verified end-to-end.** +- **`mregion`** (`app/temporal.py`, `map/MapView.tsx`): a moving region is units + of faces whose *vertices* each move linearly (`xStart yStart xEnd yEnd`), so + the polygon is rebuilt at the current instant every frame — the same + piecewise-linear model as moving points. `mrain` / `msnow` drift across the + map on the timeline, and work under the BerlinMOD projection. +- **`mreal` / `mint` / `mbool` value plots** (`app/temporal.py`, + `plots/PlotPanel.tsx`): scalar moving values are sampled into a series and + drawn as **small multiples** — one plot per measure, each with its own y + scale (never a second y-axis), coloured by its layer, directly labelled, with + a cursor and readout that track the timeline. A `ureal` unit is + `a·t² + b·t + c` (square-rooted when its flag is TRUE) with **t in days** — + verified against SECONDO's own `val(… atinstant …)` to the 8th decimal. + Objects with no geometry (e.g. `mreal5000`) plot without touching the map. + +### A note on coordinates & the basemap + +SECONDO spatial values carry coordinates in the dataset's *own* world system +(berlintest uses a local planar system, not WGS84), so the default view is a +non-geographic **Cartesian** one — correct for any dataset, with no basemap. +Geographic rendering on an **OSM basemap** is opt-in on top of that, exactly as +the HoeseViewer treats its OSM background: automatic for datasets already in +lon/lat, and via the BerlinMOD transform for berlintest. See **Projections** +below. + +## Prerequisites + +- A built SECONDO tree with the environment sourced (`source ~/.secondorc`, which + sets `SECONDO_BUILD_DIR`, `SECONDO_PLATFORM`, `BERKELEY_DB_DIR`). +- The prebuilt client lib `apis/api_cpp/cs/lib/libsecondo.a` (built via + `cd apis/api_cpp/cs && make`). +- Python 3.11+ and Node 20.19+ (required by Vite 8). + +## Build & run + +### 1. Backend (native module + FastAPI) + +```bash +source ~/.secondorc +cd WebUI/backend +python3 -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt + +# Build the pybind11 wrapper over libsecondo.a +make -C native + +# Run the bridge (reads SECONDO_HOST/PORT/CONFIG from env; defaults to +# 127.0.0.1:1234 and $SECONDO_BUILD_DIR/bin/SecondoConfig.ini) +uvicorn app.main:app --host 127.0.0.1 --port 8000 +``` + +### 2. A running SecondoMonitor + +```bash +cd $SECONDO_BUILD_DIR/bin +./SecondoMonitor -s # listens on port 1234 +``` + +### 3. Frontend + +```bash +cd WebUI/frontend +npm install +npm run dev # http://localhost:5173 (proxies /api -> :8000) +``` + +Open http://localhost:5173 and try (each spatial result draws on the map): + +``` +open database berlintest +query mehringdamm # a point +query BGrenzenLine # district boundaries (line) +query thecenter # a region +query Flaechen feed head[5] consume # a relation of regions +query Trains feed head[3] project[Id, Trip] consume # moving points -> animated +``` + +## Tests + +Backend unit/API tests (no live monitor required — the connection is faked): + +```bash +cd WebUI/backend && . .venv/bin/activate +python -m pytest # nested-list parser, GeoJSON converter, API routes +``` + +Frontend end-to-end map test (needs the full stack up: monitor + bridge + vite): + +```bash +cd WebUI/frontend +npm run e2e # the whole suite +npm run e2e -- plots # only checks whose name matches "plots" +``` + +`e2e/run.mjs` discovers every `e2e/verify-*.mjs`, runs them sequentially and +reports pass/fail by **exit code** (each check is a standalone program), echoing +the output of any failure. It preflights the stack and tells you what is missing +rather than dumping navigation timeouts. Screenshots land in `e2e/out/`. +Override with `CHROMIUM=`, `WEBUI_URL=`, `WEBUI_API=`. + +Current checks: `animation`, `catalog-basemap`, `catalog-race`, `console`, +`layers`, `map`, `mpoint-fit`, `mregion`, `plots`, `projection`, `remove-layer`, +`render-modes`, `ui-polish`, `viewfit`. + +Real end-to-end sanity check (needs a monitor + berlintest): +`query mehringdamm` should return `(point (9396.0 9871.0))` and draw a dot. + +## Layout + +``` +backend/ + native/ pybind11 wrapper (secondo_native.cpp, Makefile) + app/ FastAPI app: config, session, main, + nlparser + geojson (static) + temporal (moving) + convert + tests/ parser / geojson / temporal / API tests + fixtures +frontend/ + src/api/ bridge client + types + src/catalog/ database + object browser + src/console/ command console (history recall, focus retention) + src/layers/ useLayers state + LayersPanel (style/reorder) + GeoJSON export + src/map/ deck.gl MapView (Cartesian OR geographic MapLibre + OSM) + + projection (Berlin2WGS) + src/plots/ PlotPanel: mreal/mint value plots as small multiples + src/timeline/ useAnimator hook + Timeline controls + e2e/ headless-browser checks + run.mjs (the suite runner) +``` + +## Projections + +A projection selector on the map (top-left) controls how world coordinates are +placed: + +- **Flat (no map)** — the Cartesian orthographic view (default); correct for any + dataset, no basemap. +- **BerlinMOD → OSM** — applies SECONDO's `Berlin2WGS` BBBike→WGS84 transform + (`src/map/projection.ts`, constants from `Algebras/Spatial/Berlin2WGS.cpp`) to + every layer, so berlintest / BerlinMOD data lands at its real Berlin location + on the OpenStreetMap basemap (e.g. `mehringdamm` → 13.389°E, 52.495°N). Points, + lines, regions and moving objects are all projected. + +Datasets already in WGS84 (e.g. SYMTRAJSMALL `EdgesExtDo`) render on OSM +automatically under "Flat" via lon/lat detection — leave the selector on Flat for +those. + +## Layout + +The map is the product, so the layout defaults to giving it the space: + +- **Console docked to the bottom** by default (the map keeps the full width) and + kept short. Toggle it to the left with `⇦ left` / `⇩ bottom`. +- **Query history collapses** (`▾ history`) to leave just the command input as a + slim bar — the history is rarely needed at length, and the map takes the + freed height. +- **Catalog collapses** to a thin rail (`◂` / `▸`), handing its width to the map. +- **All panels are drag-resizable** via the splitters between them. +- Layout, sizes and collapse state persist in `localStorage`. + +Note that the map only auto-fits when new data arrives or the projection +changes — editing a layer's style, toggling visibility or removing a layer +leaves the view exactly where you put it. Use the `⤢` button to re-fit on demand. + +## UX / rendering options + +- **Loading indicator** while a query runs. +- **Moving-object render mode** (per layer, in the style editor): `trail` + (animated fading trail), `positions` (a dot at each object's exact current + position — clearer when many objects move at once), or `both`. The layer's + **point radius** sizes the position dots and **line width** sizes the trail, + so those style controls now affect moving objects too, not just static ones. +- Internal keys (`_attr`, `_layer`) are hidden from the map tooltip and the + selection details panel. +- **View fitting & zoom controls:** the map view is controlled and auto-fits to + the visible data when new data arrives or the projection changes (so toggling + BerlinMOD→OSM re-centers instead of showing the whole world). On-map buttons + provide zoom in / zoom out / fit-to-data. + +## Notes + +- **Text encoding:** SECONDO stores strings in Latin-1 (ISO-8859-1). The native + binding decodes results as Latin-1 (lossless) rather than UTF-8, so names with + umlauts (e.g. `Stölpchensee`, `UFA-Filmbühne Wien` in berlintest `Kinos` / + `WFlaechen`) round-trip correctly instead of crashing the query. +- **Error handling:** any backend failure returns a JSON body (global exception + handler), and the frontend parses responses defensively, so a server error can + never surface as a raw "JSON.parse: unexpected character" in the UI. +- **Session cookie / request ordering:** API requests are serialized in a single + queue (`src/api/client.ts`) so the first request establishes the session + cookie before others fire. Without this, clicking a database immediately on + load could mint a second session and leave the object list empty. The catalog + also shows a loading spinner while objects are fetched and ignores repeat + clicks while a database is opening. +- **Connections are a scarce resource.** Every session holds one SECONDO + connection and the server forks a process (`SecondoBDB -srv`) per connection, + so they must be reclaimed: + - the frontend releases its session on `pagehide` via `sendBeacon`; + - the backend reaps sessions idle beyond `SECONDO_SESSION_IDLE_TIMEOUT` + (default 1800s, swept every `SECONDO_SESSION_REAP_INTERVAL`, default 60s), + never reaping one that is mid-command; + - all sessions are closed on shutdown. +- **The native binding must never hold the GIL during server I/O.** + `Connection`'s connect/terminate/`secondo` all release it. A connect that + hangs (slow or wedged SECONDO) would otherwise freeze the whole Python + process — including endpoints that never touch SECONDO. + +## Roadmap (next) + +- Streaming large results over the WebSocket (`/api/stream`) and query-history + persistence. +- Include the optimizer (add it to the REST backend, implement the port), allow + to run SQL-line queries from the WebUI. In the existing UI, you need to start + the OptServer for that. +- Additional projections beyond BerlinMOD as needed. +- Remaining long-tail types (network/JNet, precise geometry, raster) still fall + back to the textual nested-list view, as `DsplGeneric` does in the Java GUI. +- Implement a regular table view that also allows updates (like the UpdateViewer + in the Java GUI). diff --git a/WebUI/backend/app/__init__.py b/WebUI/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/WebUI/backend/app/catalog.py b/WebUI/backend/app/catalog.py new file mode 100644 index 000000000..23fbc45cc --- /dev/null +++ b/WebUI/backend/app/catalog.py @@ -0,0 +1,73 @@ +"""Parse SECONDO's ``list objects`` inquiry into a browsable catalog. + +Result shape: + + (inquiry (objects (OBJECTS + (OBJECT () ()) + ...))) + +For each object we surface its name, a short type label (the outermost type +constructor, e.g. ``rel`` / ``mpoint`` / ``region``) and a ``kind`` hint +(``spatial`` / ``temporal`` / ``other``) so the UI can flag what will draw. +""" +from __future__ import annotations + +from .nlparser import Node, parse + +_SPATIAL = {"point", "points", "line", "sline", "dline", "region", "rect"} +_TEMPORAL = { + "mpoint", "mregion", "mreal", "mint", "mbool", "mstring", + "mlabel", "mlabels", "mplace", "mplaces", "upoint", "intime", +} + + +def _symbols(node: Node) -> list[str]: + out: list[str] = [] + if isinstance(node, str): + out.append(node) + elif isinstance(node, list): + for n in node: + out.extend(_symbols(n)) + return out + + +def _first_symbol(node: Node) -> str | None: + for s in _symbols(node): + return s + return None + + +def parse_objects(text: str) -> list[dict]: + tree = parse(text) + # Drill down to the OBJECTS list. + try: + # (inquiry (objects (OBJECTS obj*))) + objects = tree[1][1] # type: ignore[index] + if not (isinstance(objects, list) and objects and objects[0] == "OBJECTS"): + return [] + entries = objects[1:] + except (IndexError, TypeError): + return [] + + result: list[dict] = [] + for obj in entries: + if not (isinstance(obj, list) and len(obj) >= 4 and obj[0] == "OBJECT"): + continue + name = obj[1] + type_expr = obj[3] + syms = set(_symbols(type_expr)) + if syms & _TEMPORAL: + kind = "temporal" + elif syms & _SPATIAL: + kind = "spatial" + else: + kind = "other" + result.append( + { + "name": name, + "type": _first_symbol(type_expr) or "?", + "kind": kind, + } + ) + result.sort(key=lambda o: str(o["name"]).lower()) + return result diff --git a/WebUI/backend/app/config.py b/WebUI/backend/app/config.py new file mode 100644 index 000000000..59959a8ea --- /dev/null +++ b/WebUI/backend/app/config.py @@ -0,0 +1,45 @@ +"""Runtime configuration for the SECONDO web bridge. + +All values come from the environment so nothing (host/port/credentials) is +hard-coded -- unlike the retired WebGui2, which baked in a personal hostname. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# The compiled `secondo_native` extension lives in ../native next to this +# package. Make it importable without an install step. +_NATIVE_DIR = Path(__file__).resolve().parent.parent / "native" +if str(_NATIVE_DIR) not in sys.path: + sys.path.insert(0, str(_NATIVE_DIR)) + + +class Settings: + # SECONDO server the bridge connects to on behalf of browser sessions. + secondo_host: str = os.environ.get("SECONDO_HOST", "127.0.0.1") + secondo_port: str = os.environ.get("SECONDO_PORT", "1234") + secondo_user: str = os.environ.get("SECONDO_USER", "") + secondo_passwd: str = os.environ.get("SECONDO_PASSWD", "") + + # Path to a SecondoConfig.ini (the client reads a few runtime flags from it). + secondo_config: str = os.environ.get( + "SECONDO_CONFIG", + str(Path(os.environ.get("SECONDO_BUILD_DIR", "")) / "bin" / "SecondoConfig.ini"), + ) + + # CORS origin for the Vite dev server. + cors_origin: str = os.environ.get("WEBUI_CORS_ORIGIN", "http://localhost:5173") + + # Each session holds a SECONDO connection (the server forks a process per + # connection), so idle sessions are closed to avoid leaking them. + session_idle_timeout: float = float( + os.environ.get("SECONDO_SESSION_IDLE_TIMEOUT", "1800") # 30 min + ) + session_reap_interval: float = float( + os.environ.get("SECONDO_SESSION_REAP_INTERVAL", "60") + ) + + +settings = Settings() diff --git a/WebUI/backend/app/convert.py b/WebUI/backend/app/convert.py new file mode 100644 index 000000000..b659a0ee9 --- /dev/null +++ b/WebUI/backend/app/convert.py @@ -0,0 +1,16 @@ +"""Parse a SECONDO result nested list once and derive all render payloads. + +Keeps parsing in one place so ``/api/query`` can attach both the static +GeoJSON (Milestone 2) and the temporal trips (Milestone 3) without walking the +text twice. +""" +from __future__ import annotations + +from . import geojson, temporal +from .nlparser import parse + + +def convert(nested_text: str) -> tuple[dict | None, dict | None]: + """Return ``(geojson, temporal)`` for a result nested list (text).""" + tree = parse(nested_text) + return geojson.from_tree(tree), temporal.from_tree(tree) diff --git a/WebUI/backend/app/geojson.py b/WebUI/backend/app/geojson.py new file mode 100644 index 000000000..96652edd7 --- /dev/null +++ b/WebUI/backend/app/geojson.py @@ -0,0 +1,213 @@ +"""Convert parsed SECONDO nested lists into GeoJSON. + +This mirrors, in spirit, the Hoese display classes +(``Javagui/viewer/hoese/algebras/Dspl*.java``) but emits GeoJSON geometries +instead of ``java.awt.Shape``. Milestone 2 covers the static spatial types; +moving/temporal types are added in Milestone 3. + +IMPORTANT about coordinates: SECONDO spatial values carry coordinates in the +dataset's own world/coordinate system (e.g. berlintest uses a local planar +system, not WGS84). The GeoJSON produced here therefore holds *native* world +coordinates. The frontend renders them in a Cartesian view; geographic +reprojection / OSM background is a later milestone. +""" +from __future__ import annotations + +import math +from typing import Any + +from .nlparser import Node, parse + +# Static spatial types understood so far (attribute-type names as they appear +# in a relation's tuple schema). +SPATIAL_TYPES = {"point", "points", "line", "sline", "dline", "region", "rect"} + +_UNDEF = {"undef", "undefined"} + + +def _num(x: Any) -> float | None: + if isinstance(x, bool): + return None + if isinstance(x, (int, float)): + return float(x) + return None + + +def _coord(pair: Any) -> list[float] | None: + """A ``(x y)`` nested list -> ``[x, y]``.""" + if not isinstance(pair, list) or len(pair) < 2: + return None + x, y = _num(pair[0]), _num(pair[1]) + if x is None or y is None: + return None + return [x, y] + + +def geometry_from(type_name: str, value: Node) -> dict | None: + """Build a GeoJSON geometry from a type name and its *value* nested list.""" + if isinstance(value, str) and value in _UNDEF: + return None + + if type_name == "point": + c = _coord(value) + return {"type": "Point", "coordinates": c} if c else None + + if type_name == "points": + if not isinstance(value, list): + return None + pts = [c for c in (_coord(p) for p in value) if c] + return {"type": "MultiPoint", "coordinates": pts} if pts else None + + if type_name in ("line", "sline", "dline"): + if not isinstance(value, list): + return None + # sline/dline wrap the segment list with a direction flag: + # (sline ( (seg*) bool )) vs (line ( seg* )) + segments = value + if ( + len(value) == 2 + and isinstance(value[0], list) + and isinstance(value[1], bool) + ): + segments = value[0] + segs = [] + for seg in segments: + if isinstance(seg, list) and len(seg) >= 4: + a, b, c, d = (_num(seg[0]), _num(seg[1]), _num(seg[2]), _num(seg[3])) + if None not in (a, b, c, d): + segs.append([[a, b], [c, d]]) + return {"type": "MultiLineString", "coordinates": segs} if segs else None + + if type_name == "region": + return _region(value) + + if type_name == "rect": + if isinstance(value, list) and len(value) >= 4: + x1, x2, y1, y2 = (_num(value[0]), _num(value[1]), + _num(value[2]), _num(value[3])) + if None not in (x1, x2, y1, y2): + ring = [[x1, y1], [x2, y1], [x2, y2], [x1, y2], [x1, y1]] + return {"type": "Polygon", "coordinates": [ring]} + return None + + return None + + +def _region(value: Node) -> dict | None: + """SECONDO region: a list of faces; each face is a list of cycles; the + first cycle is the outer boundary, the rest are holes.""" + if not isinstance(value, list): + return None + polygons = [] + for face in value: + if not isinstance(face, list): + continue + rings = [] + for cycle in face: + if not isinstance(cycle, list): + continue + ring = [c for c in (_coord(p) for p in cycle) if c] + if len(ring) < 3: + continue + if ring[0] != ring[-1]: # GeoJSON rings must be closed + ring.append(ring[0]) + rings.append(ring) + if rings: + polygons.append(rings) + if not polygons: + return None + return {"type": "MultiPolygon", "coordinates": polygons} + + +def _feature(geometry: dict, properties: dict | None = None) -> dict: + return {"type": "Feature", "geometry": geometry, "properties": properties or {}} + + +def _scalar(v: Any) -> Any | None: + """Keep only scalar attribute values as GeoJSON properties.""" + if isinstance(v, (int, float, bool, str)): + return v + return None + + +def _relation_features(type_expr: list, tuples: Node) -> list[dict]: + # type_expr = ['rel', ['tuple', [[name, atype], ...]]] + try: + attrs = type_expr[1][1] + names = [a[0] for a in attrs] + types = [a[1] for a in attrs] + except (IndexError, TypeError): + return [] + spatial_idx = [i for i, t in enumerate(types) if t in SPATIAL_TYPES] + if not spatial_idx or not isinstance(tuples, list): + return [] + + features: list[dict] = [] + for tup in tuples: + if not isinstance(tup, list): + continue + props = { + names[i]: _scalar(tup[i]) + for i in range(min(len(names), len(tup))) + if i not in spatial_idx and _scalar(tup[i]) is not None + } + for i in spatial_idx: + if i < len(tup): + geom = geometry_from(types[i], tup[i]) + if geom: + features.append(_feature(geom, {**props, "_attr": names[i]})) + return features + + +def _iter_coords(geometry: dict): + def walk(coords): + if (isinstance(coords, list) and len(coords) == 2 + and all(isinstance(v, (int, float)) for v in coords)): + yield coords + elif isinstance(coords, list): + for c in coords: + yield from walk(c) + + yield from walk(geometry.get("coordinates", [])) + + +def _bbox(features: list[dict]) -> list[float] | None: + minx = miny = math.inf + maxx = maxy = -math.inf + for f in features: + for x, y in _iter_coords(f["geometry"]): + minx, miny = min(minx, x), min(miny, y) + maxx, maxy = max(maxx, x), max(maxy, y) + if minx is math.inf: + return None + return [minx, miny, maxx, maxy] + + +def from_tree(tree: Node) -> dict | None: + """Turn a parsed ``(type value)`` (or relation) tree into a + GeoJSON FeatureCollection, or ``None`` if nothing spatial is present.""" + if not isinstance(tree, list) or len(tree) < 2: + return None + + type_expr, value = tree[0], tree[1] + features: list[dict] = [] + + if isinstance(type_expr, str): # atomic spatial type: (point (...)) etc. + geom = geometry_from(type_expr, value) + if geom: + features.append(_feature(geom)) + elif isinstance(type_expr, list) and type_expr and type_expr[0] == "rel": + features = _relation_features(type_expr, value) + + if not features: + return None + fc: dict = {"type": "FeatureCollection", "features": features} + bbox = _bbox(features) + if bbox: + fc["bbox"] = bbox + return fc + + +def to_geojson(nested_text: str) -> dict | None: + """Parse nested-list text and convert to a GeoJSON FeatureCollection.""" + return from_tree(parse(nested_text)) diff --git a/WebUI/backend/app/main.py b/WebUI/backend/app/main.py new file mode 100644 index 000000000..94bc5c4e0 --- /dev/null +++ b/WebUI/backend/app/main.py @@ -0,0 +1,155 @@ +"""FastAPI bridge between the web UI and a SECONDO server. + +Milestone 1 (pipeline spike): connect, run a command, return the result nested +list as text. GeoJSON conversion is layered on top in Milestone 2 (the response +already carries a `geojson` field, currently null). +""" +from __future__ import annotations + +import asyncio +import contextlib +import logging +import re +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import Cookie, FastAPI, HTTPException, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from .catalog import parse_objects +from .config import settings +from .convert import convert +from .session import Session, manager + +logger = logging.getLogger("secondo.webui") + +SESSION_COOKIE = "secondo_sid" + + +async def _reaper() -> None: + """Periodically close idle sessions so their SECONDO connections (and the + per-connection server processes) are reclaimed.""" + while True: + await asyncio.sleep(settings.session_reap_interval) + try: + n = await manager.reap(settings.session_idle_timeout) + if n: + logger.info("Closed %d idle SECONDO session(s)", n) + except Exception: # noqa: BLE001 - the reaper must never die + logger.exception("Session reaper failed") + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + task = asyncio.create_task(_reaper()) + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + with contextlib.suppress(Exception): + await manager.close_all() + + +app = FastAPI(title="SECONDO Web UI bridge", version="0.1.0", lifespan=lifespan) + + +@app.exception_handler(Exception) +async def unhandled_exception(_request: Request, exc: Exception) -> JSONResponse: + """Guarantee a JSON body for *any* failure so the browser never receives a + plain-text 500 (which would fail `response.json()`).""" + logger.exception("Unhandled error") + return JSONResponse( + status_code=500, content={"detail": f"{type(exc).__name__}: {exc}"} + ) +app.add_middleware( + CORSMiddleware, + allow_origins=[settings.cors_origin], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +class QueryRequest(BaseModel): + command: str + + +class QueryResponse(BaseModel): + text: str # result nested list as text + geojson: dict | None = None # static spatial features (Milestone 2) + temporal: dict | None = None # moving-object trips + time domain (Milestone 3) + + +async def _session_for(response: Response, sid: str | None) -> Session: + """Return the session for this cookie, creating one on first contact.""" + session = manager.get(sid) + if session is None: + session = await manager.create() + response.set_cookie( + SESSION_COOKIE, session.id, httponly=True, samesite="lax" + ) + return session + + +@app.get("/api/health") +async def health() -> dict: + return {"status": "ok", "secondo": f"{settings.secondo_host}:{settings.secondo_port}"} + + +@app.post("/api/query", response_model=QueryResponse) +async def query( + req: QueryRequest, + response: Response, + secondo_sid: str | None = Cookie(default=None), +) -> QueryResponse: + session = await _session_for(response, secondo_sid) + try: + text = await session.run(req.command) + except RuntimeError as exc: # SECONDO error / connection error + raise HTTPException(status_code=400, detail=str(exc)) from exc + # Track the open database so the UI can show it. + m = re.match(r"\s*open\s+database\s+(\w+)", req.command, re.IGNORECASE) + if m: + session.open_db = m.group(1) + # Best-effort conversion; never let it break a successful query. + geojson = temporal = None + try: + geojson, temporal = convert(text) + except Exception: # noqa: BLE001 - conversion must not fail the request + logger.exception("Result conversion failed for command: %s", req.command) + return QueryResponse(text=text, geojson=geojson, temporal=temporal) + + +@app.get("/api/databases") +async def databases( + response: Response, secondo_sid: str | None = Cookie(default=None) +) -> dict: + session = await _session_for(response, secondo_sid) + text = await session.run("list databases") + # (inquiry (databases (NAME1 NAME2 ...))) -> pull the names out + names = re.findall(r"\b[A-Z][A-Z0-9_]*\b", text) + return {"databases": names, "open": session.open_db} + + +@app.get("/api/objects") +async def objects( + response: Response, secondo_sid: str | None = Cookie(default=None) +) -> dict: + """List objects in the currently open database (name/type/kind).""" + session = await _session_for(response, secondo_sid) + try: + text = await session.run("list objects") + except RuntimeError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"objects": parse_objects(text), "open": session.open_db} + + +@app.post("/api/close") +async def close(secondo_sid: str | None = Cookie(default=None)) -> dict: + if secondo_sid: + await manager.close(secondo_sid) + return {"closed": True} diff --git a/WebUI/backend/app/nlparser.py b/WebUI/backend/app/nlparser.py new file mode 100644 index 000000000..717056d7a --- /dev/null +++ b/WebUI/backend/app/nlparser.py @@ -0,0 +1,123 @@ +"""Parser for SECONDO nested lists in their *text* representation. + +The heavy lifting (wire protocol, binary list decoding) happens in the trusted +C++ client; what reaches Python is a nested list already rendered as text by +``NestedList::ToString``. This module turns that text into a Python tree so the +GeoJSON converter can walk it. Keeping only text parsing on the Python side is +deliberate -- it is small and easy to fixture-test. + +Grammar (informal): + + list := '(' item* ')' + item := list | atom + atom := int | real | bool | string | symbol | text + +Atoms are returned as native Python values: + +* int -> int +* real -> float +* TRUE/FALSE -> bool +* "..." -> str (quoted string; quotes stripped) +* ... -> str +* symbol -> str (e.g. type names like ``point``) + +Quoted strings and symbols both become ``str``; the distinction never matters +for the positions the GeoJSON converter inspects (a type name is always a +symbol, never a quoted string). +""" +from __future__ import annotations + +from typing import Union + +Node = Union[list, int, float, bool, str] + +_TEXT_OPEN = "" +_TEXT_CLOSE = "" + + +def _classify(token: str) -> Node: + """Turn a bare atom token into a Python value.""" + try: + return int(token) + except ValueError: + pass + try: + return float(token) + except ValueError: + pass + if token == "TRUE": + return True + if token == "FALSE": + return False + return token # symbol (or undefined marker like ``undef``) + + +def parse(text: str) -> Node: + """Parse a nested-list text into a Python tree. Returns the first element.""" + pos = 0 + n = len(text) + + def skip_ws() -> None: + nonlocal pos + while pos < n and text[pos].isspace(): + pos += 1 + + def parse_item() -> Node: + nonlocal pos + skip_ws() + if pos >= n: + raise ValueError("unexpected end of nested list") + c = text[pos] + if c == "(": + return parse_list() + if c == ")": + raise ValueError(f"unexpected ')' at {pos}") + if c == '"': + return parse_string() + if text.startswith(_TEXT_OPEN, pos): + return parse_text_atom() + return parse_atom() + + def parse_list() -> list: + nonlocal pos + pos += 1 # consume '(' + items: list[Node] = [] + while True: + skip_ws() + if pos >= n: + raise ValueError("unterminated list") + if text[pos] == ")": + pos += 1 + return items + items.append(parse_item()) + + def parse_string() -> str: + nonlocal pos + pos += 1 # consume opening quote + start = pos + while pos < n and text[pos] != '"': + pos += 1 + s = text[start:pos] + pos += 1 # consume closing quote + return s + + def parse_text_atom() -> str: + nonlocal pos + start = pos + len(_TEXT_OPEN) + end = text.find(_TEXT_CLOSE, start) + if end == -1: + end = n + pos = n + else: + pos = end + len(_TEXT_CLOSE) + return text[start:end] + + def parse_atom() -> Node: + nonlocal pos + start = pos + while pos < n and not text[pos].isspace() and text[pos] not in "()": + pos += 1 + return _classify(text[start:pos]) + + result = parse_item() + return result diff --git a/WebUI/backend/app/session.py b/WebUI/backend/app/session.py new file mode 100644 index 000000000..71bb713a1 --- /dev/null +++ b/WebUI/backend/app/session.py @@ -0,0 +1,105 @@ +"""Per-browser-session SECONDO connections. + +Mirrors WebGui2's per-session connection model: each browser session owns one +`secondo_native.Connection`. A Connection is not thread-safe and SECONDO runs +one command at a time per client, so every command on a session is serialized +behind an asyncio lock and the blocking C++ call is run in a worker thread. +""" +from __future__ import annotations + +import asyncio +import logging +import secrets +import time +from dataclasses import dataclass, field + +import secondo_native # provided by ../native (see config.py) + +from .config import settings + +logger = logging.getLogger("secondo.webui") + + +@dataclass +class Session: + id: str + conn: "secondo_native.Connection" + open_db: str | None = None + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + # Monotonic timestamp of the last use, for idle reaping. + last_used: float = field(default_factory=time.monotonic) + + def touch(self) -> None: + self.last_used = time.monotonic() + + async def run(self, command: str) -> str: + """Execute one command; returns the result nested list as text.""" + async with self.lock: + self.touch() + try: + return await asyncio.to_thread(self.conn.secondo, command) + finally: + self.touch() + + +class SessionManager: + def __init__(self) -> None: + self._sessions: dict[str, Session] = {} + self._create_lock = asyncio.Lock() + + async def create(self) -> Session: + """Open a fresh SECONDO connection and register a session for it.""" + async with self._create_lock: + conn = await asyncio.to_thread( + secondo_native.Connection, + settings.secondo_host, + settings.secondo_port, + settings.secondo_user, + settings.secondo_passwd, + settings.secondo_config, + ) + sid = secrets.token_urlsafe(24) + session = Session(id=sid, conn=conn) + self._sessions[sid] = session + return session + + def get(self, sid: str | None) -> Session | None: + if not sid: + return None + session = self._sessions.get(sid) + if session is not None: + session.touch() # keep active sessions from being reaped + return session + + async def close(self, sid: str) -> None: + session = self._sessions.pop(sid, None) + if session is not None: + await asyncio.to_thread(session.conn.close) + + async def reap(self, max_idle: float) -> int: + """Close sessions idle longer than `max_idle` seconds. + + Every session holds a SECONDO connection, and the server forks a + process per connection -- without this, closed tabs and crashed + clients would leak server processes indefinitely. Sessions currently + running a command are never reaped. + """ + now = time.monotonic() + closed = 0 + for sid, session in list(self._sessions.items()): + if session.lock.locked(): + continue # busy running a command + if now - session.last_used > max_idle: + await self.close(sid) + closed += 1 + return closed + + async def close_all(self) -> None: + for sid in list(self._sessions): + await self.close(sid) + + def count(self) -> int: + return len(self._sessions) + + +manager = SessionManager() diff --git a/WebUI/backend/app/temporal.py b/WebUI/backend/app/temporal.py new file mode 100644 index 000000000..64b30e069 --- /dev/null +++ b/WebUI/backend/app/temporal.py @@ -0,0 +1,424 @@ +"""Convert SECONDO moving/temporal nested lists into animation trips. + +Milestone 3 handles ``mpoint`` (and relations carrying ``mpoint`` attributes), +the signature HoeseViewer feature. An ``mpoint`` is a list of *units*: + + (mpoint ( unit* )) + unit = ( interval mapping ) + interval = ( "start-instant" "end-instant" lc rc ) + mapping = ( x0 y0 x1 y1 ) ; linear from (x0,y0)@start to (x1,y1)@end + +This mirrors the piecewise-linear model the HoeseViewer interpolates at render +time (``Dsplmovingpoint`` + ``IntervalSearch``). We emit deck.gl ``TripsLayer`` +data: for each continuous run of units, a ``path`` of waypoints and a matching +list of ``timestamps`` (seconds). The frontend interpolates position from a +single animated ``currentTime`` -- the same math, moved to the GPU/JS side. +""" +from __future__ import annotations + +import datetime as _dt +import math + +from .nlparser import Node + +MOVING_POINT_TYPES = {"mpoint"} +MOVING_REGION_TYPES = {"mregion"} +# Scalar-valued moving types: plotted as a value/time series rather than drawn. +PLOT_TYPES = {"mreal", "mint", "mbool"} +MOVING_TYPES = MOVING_POINT_TYPES | MOVING_REGION_TYPES | PLOT_TYPES + +# Instants SECONDO uses for unbounded intervals. +_UNBOUNDED = {"begin of time", "end of time"} +# Samples per non-linear mreal unit (a quadratic needs a few points to curve). +_UREAL_SAMPLES = 16 +_SECONDS_PER_DAY = 86400.0 + +_UNDEF = {"undef", "undefined"} +# Split a moving point into separate trips when the temporal gap between two +# consecutive units exceeds this (seconds); within a run, units are contiguous. +_GAP_EPS = 1e-3 + + +def _num(x: object) -> float | None: + if isinstance(x, bool): + return None + if isinstance(x, (int, float)): + return float(x) + return None + + +def parse_instant(s: str) -> float | None: + """SECONDO instant string -> POSIX seconds (UTC). + + Formats seen: ``2003-11-20`` (date only -> midnight), ``2003-11-20-06:03``, + ``2003-11-20-06:04:50``, ``2003-11-20-06:03:52.685`` (date and time + separated by ``-``). Unbounded markers ("begin of time" / "end of time") + return None; callers resolve them against the object's finite extent. + """ + if not isinstance(s, str): + return None + s = s.strip() + if len(s) == 10: # date only + date_part, time_part = s, "" + elif len(s) >= 12 and s[10] == "-": + date_part, time_part = s[:10], s[11:] + else: + return None + try: + year, month, day = (int(v) for v in date_part.split("-")) + hour = minute = 0 + seconds = 0.0 + if time_part: + hms = time_part.split(":") + hour = int(hms[0]) + minute = int(hms[1]) + seconds = float(hms[2]) if len(hms) > 2 else 0.0 + whole = int(seconds) + micro = int(round((seconds - whole) * 1_000_000)) + dt = _dt.datetime( + year, month, day, hour, minute, whole, micro, + tzinfo=_dt.timezone.utc, + ) + except (ValueError, IndexError): + return None + return dt.timestamp() + + +def _parse_unit(unit: Node) -> tuple[float, float, float, float, float, float] | None: + try: + interval, mapping = unit[0], unit[1] # type: ignore[index] + t0 = parse_instant(interval[0]) + t1 = parse_instant(interval[1]) + if t0 is None or t1 is None: + return None + x0, y0, x1, y1 = (float(mapping[i]) for i in range(4)) + except (IndexError, TypeError, ValueError): + return None + return t0, t1, x0, y0, x1, y1 + + +def mpoint_to_trips(value: Node, properties: dict | None = None) -> list[dict]: + """Turn an ``mpoint`` value (list of units) into TripsLayer trips.""" + if isinstance(value, str) and value in _UNDEF: + return [] + if not isinstance(value, list): + return [] + + props = properties or {} + trips: list[dict] = [] + path: list[list[float]] = [] + timestamps: list[float] = [] + prev_t1: float | None = None + + def flush() -> None: + if len(path) >= 2: + trips.append( + {"path": list(path), "timestamps": list(timestamps), "properties": props} + ) + + for unit in value: + parsed = _parse_unit(unit) + if parsed is None: + continue + t0, t1, x0, y0, x1, y1 = parsed + if prev_t1 is None or t0 > prev_t1 + _GAP_EPS: + flush() + path = [[x0, y0]] + timestamps = [t0] + path.append([x1, y1]) + timestamps.append(t1) + prev_t1 = t1 + flush() + return trips + + +def mregion_to_moving(value: Node, properties: dict | None = None) -> dict | None: + """Turn an ``mregion`` value into time-interpolatable moving faces. + + (mregion ( uregion* )) + uregion = ( interval ( face* ) ) + face = ( cycle* ) ; first cycle outer, rest holes + cycle = ( vertex* ) + vertex = ( xStart yStart xEnd yEnd ) ; moves linearly across the unit + + Emitted shape: ``{units: [{interval: [t0, t1], faces: [[cycle, ...], ...]}]}`` + so the client can interpolate each vertex at the current instant -- the same + piecewise-linear model as moving points. + """ + if isinstance(value, str) and value in _UNDEF: + return None + if not isinstance(value, list): + return None + + units: list[dict] = [] + for uregion in value: + try: + interval, faces = uregion[0], uregion[1] # type: ignore[index] + t0 = parse_instant(interval[0]) + t1 = parse_instant(interval[1]) + except (IndexError, TypeError): + continue + if t0 is None or t1 is None or not isinstance(faces, list): + continue + + parsed_faces: list[list[list[list[float]]]] = [] + for face in faces: + if not isinstance(face, list): + continue + cycles: list[list[list[float]]] = [] + for cycle in face: + if not isinstance(cycle, list): + continue + verts: list[list[float]] = [] + for v in cycle: + if isinstance(v, list) and len(v) >= 4: + vals = [_num(v[i]) for i in range(4)] + if None not in vals: + verts.append([float(x) for x in vals]) # type: ignore[arg-type] + if len(verts) >= 3: + cycles.append(verts) + if cycles: + parsed_faces.append(cycles) + if parsed_faces: + units.append({"interval": [t0, t1], "faces": parsed_faces}) + + if not units: + return None + return {"units": units, "properties": properties or {}} + + +def _ureal_value(a: float, b: float, c: float, root: bool, t_days: float) -> float: + """Evaluate a SECONDO ``ureal`` unit function. + + The unit stores ``(a b c r)`` and the value is ``a*t^2 + b*t + c``, square + rooted when ``r`` is TRUE. ``t`` is the time since the unit's start **in + days** -- verified against SECONDO itself (`val(mreal atinstant i)`). + """ + v = a * t_days * t_days + b * t_days + c + if root: + return math.sqrt(max(v, 0.0)) # guard tiny negatives from rounding + return v + + +def _plot_units(value: Node) -> list[tuple[float, float, Node]] | None: + """Units as ``(t0, t1, unit_value)``, resolving unbounded instants + ("begin of time" / "end of time") to the object's finite extent.""" + if not isinstance(value, list): + return None + raw: list[tuple[float | None, float | None, Node]] = [] + for unit in value: + try: + interval, uval = unit[0], unit[1] # type: ignore[index] + raw.append((parse_instant(interval[0]), parse_instant(interval[1]), uval)) + except (IndexError, TypeError): + continue + finite = [t for a, b, _ in raw for t in (a, b) if t is not None] + if not finite: + return None + lo, hi = min(finite), max(finite) + return [(a if a is not None else lo, b if b is not None else hi, v) for a, b, v in raw] + + +def scalar_to_plot(type_name: str, value: Node, label: str) -> dict | None: + """Turn an ``mreal`` / ``mint`` / ``mbool`` into a value-over-time series.""" + if isinstance(value, str) and value in _UNDEF: + return None + units = _plot_units(value) + if not units: + return None + + series: list[list[float]] = [] + for t0, t1, uval in units: + if type_name == "mreal": + try: + a, b, c = (float(uval[i]) for i in range(3)) # type: ignore[index] + root = bool(uval[3]) # type: ignore[index] + except (IndexError, TypeError, ValueError): + continue + span_days = (t1 - t0) / _SECONDS_PER_DAY + # A linear unit only needs its endpoints; a quadratic needs samples. + n = 1 if a == 0 else _UREAL_SAMPLES + for i in range(n + 1): + f = i / n + series.append([t0 + f * (t1 - t0), _ureal_value(a, b, c, root, f * span_days)]) + else: # mint / mbool: piecewise constant -> a step + v = uval + if isinstance(v, bool): + v = 1.0 if v else 0.0 + num = _num(v) + if num is None: + continue + series.append([t0, num]) + series.append([t1, num]) + + if not series: + return None + values = [v for _, v in series] + return { + "label": label, + "kind": "step" if type_name in ("mint", "mbool") else "line", + "type": type_name, + "series": series, + "valueRange": [min(values), max(values)], + "timeDomain": [series[0][0], series[-1][0]], + } + + +def _plot_time_domain(plots: list[dict]) -> list[float] | None: + if not plots: + return None + return [ + min(p["timeDomain"][0] for p in plots), + max(p["timeDomain"][1] for p in plots), + ] + + +def _region_bbox(regions: list[dict]) -> list[float] | None: + minx = miny = math.inf + maxx = maxy = -math.inf + for region in regions: + for unit in region["units"]: + for face in unit["faces"]: + for cycle in face: + for x0, y0, x1, y1 in cycle: + minx = min(minx, x0, x1) + maxx = max(maxx, x0, x1) + miny = min(miny, y0, y1) + maxy = max(maxy, y0, y1) + if minx is math.inf: + return None + return [minx, miny, maxx, maxy] + + +def _region_time_domain(regions: list[dict]) -> list[float] | None: + tmin = math.inf + tmax = -math.inf + for region in regions: + for unit in region["units"]: + tmin = min(tmin, unit["interval"][0]) + tmax = max(tmax, unit["interval"][1]) + if tmin is math.inf: + return None + return [tmin, tmax] + + +def _merge(a: list[float] | None, b: list[float] | None, bbox: bool) -> list[float] | None: + """Union two bboxes or two time domains.""" + if a is None: + return b + if b is None: + return a + if bbox: + return [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])] + return [min(a[0], b[0]), max(a[1], b[1])] + + +def _bbox(trips: list[dict]) -> list[float] | None: + minx = miny = math.inf + maxx = maxy = -math.inf + for trip in trips: + for x, y in trip["path"]: + minx, miny = min(minx, x), min(miny, y) + maxx, maxy = max(maxx, x), max(maxy, y) + if minx is math.inf: + return None + return [minx, miny, maxx, maxy] + + +def _time_domain(trips: list[dict]) -> list[float] | None: + tmin = math.inf + tmax = -math.inf + for trip in trips: + if trip["timestamps"]: + tmin = min(tmin, trip["timestamps"][0]) + tmax = max(tmax, trip["timestamps"][-1]) + if tmin is math.inf: + return None + return [tmin, tmax] + + +def _scalar(v: object) -> object | None: + return v if isinstance(v, (int, float, bool, str)) else None + + +def _relation_moving( + type_expr: list, tuples: Node +) -> tuple[list[dict], list[dict], list[dict]]: + """Collect trips, moving regions and scalar plots from a relation.""" + try: + attrs = type_expr[1][1] + names = [a[0] for a in attrs] + types = [a[1] for a in attrs] + except (IndexError, TypeError): + return [], [], [] + moving_idx = [i for i, t in enumerate(types) if t in MOVING_TYPES] + if not moving_idx or not isinstance(tuples, list): + return [], [], [] + + trips: list[dict] = [] + regions: list[dict] = [] + plots: list[dict] = [] + for row, tup in enumerate(tuples): + if not isinstance(tup, list): + continue + props = { + names[i]: _scalar(tup[i]) + for i in range(min(len(names), len(tup))) + if i not in moving_idx and _scalar(tup[i]) is not None + } + for i in moving_idx: + if i >= len(tup): + continue + attr_props = {**props, "_attr": names[i]} + if types[i] in MOVING_POINT_TYPES: + trips.extend(mpoint_to_trips(tup[i], attr_props)) + elif types[i] in MOVING_REGION_TYPES: + region = mregion_to_moving(tup[i], attr_props) + if region: + regions.append(region) + elif types[i] in PLOT_TYPES: + # Label plots so several rows stay distinguishable. + label = names[i] if len(tuples) == 1 else f"{names[i]} #{row + 1}" + plot = scalar_to_plot(types[i], tup[i], label) + if plot: + plots.append(plot) + return trips, regions, plots + + +def from_tree(tree: Node) -> dict | None: + """Turn a parsed ``(type value)`` (or relation) tree into a temporal payload + ``{trips, regions, timeDomain, bbox}``, or ``None`` if nothing temporal.""" + if not isinstance(tree, list) or len(tree) < 2: + return None + type_expr, value = tree[0], tree[1] + + trips: list[dict] = [] + regions: list[dict] = [] + plots: list[dict] = [] + + if isinstance(type_expr, str) and type_expr in MOVING_POINT_TYPES: + trips = mpoint_to_trips(value) + elif isinstance(type_expr, str) and type_expr in MOVING_REGION_TYPES: + region = mregion_to_moving(value) + if region: + regions = [region] + elif isinstance(type_expr, str) and type_expr in PLOT_TYPES: + plot = scalar_to_plot(type_expr, value, type_expr) + if plot: + plots = [plot] + elif isinstance(type_expr, list) and type_expr and type_expr[0] == "rel": + trips, regions, plots = _relation_moving(type_expr, value) + else: + return None + + if not trips and not regions and not plots: + return None + time_domain = _merge(_time_domain(trips), _region_time_domain(regions), bbox=False) + time_domain = _merge(time_domain, _plot_time_domain(plots), bbox=False) + return { + "trips": trips, + "regions": regions, + "plots": plots, + "timeDomain": time_domain, + "bbox": _merge(_bbox(trips), _region_bbox(regions), bbox=True), + } diff --git a/WebUI/backend/native/Makefile b/WebUI/backend/native/Makefile new file mode 100644 index 000000000..1571cbc3e --- /dev/null +++ b/WebUI/backend/native/Makefile @@ -0,0 +1,35 @@ +# Builds the secondo_native Python extension by wrapping the prebuilt +# SECONDO client-server library (apis/api_cpp/cs/lib/libsecondo.a). +# +# Mirrors the include/link setup of apis/api_cpp/cs/example/makefile so it +# stays consistent with how the reference client is meant to be linked. +# +# Requires the SECONDO build environment (source ~/.secondorc) so that +# SECONDO_BUILD_DIR / SECONDO_PLATFORM / BERKELEY_DB_* are set, and a Python +# with pybind11 available (source the backend .venv). + +include $(SECONDO_BUILD_DIR)/makefile.env + +CS_DIR = $(SECONDO_BUILD_DIR)/apis/api_cpp/cs +BDBINCLUDE ?= $(BERKELEY_DB_DIR)/include +BDB_LIB_DIR ?= $(BERKELEY_DB_DIR)/lib +BERKELEY_DB_LIB ?= db_cxx + +PYINCLUDES = $(shell python -m pybind11 --includes) +EXTSUFFIX = $(shell python -c "import sysconfig;print(sysconfig.get_config_var('EXT_SUFFIX'))") + +MODULE = secondo_native$(EXTSUFFIX) + +INCLUDES = $(PYINCLUDES) -I$(CS_DIR)/include -I$(BDBINCLUDE) +LIBS = -L$(CS_DIR)/lib -lsecondo -L$(BDB_LIB_DIR) -l$(BERKELEY_DB_LIB) \ + -lpthread -lstdc++ + +.PHONY: all clean +all: $(MODULE) + +$(MODULE): secondo_native.cpp + $(CPPC) -O2 -Wall -shared -std=c++17 -fPIC $(CCFLAGS) $(INCLUDES) \ + secondo_native.cpp -o $(MODULE) $(LIBS) + +clean: + rm -f secondo_native*.so diff --git a/WebUI/backend/native/secondo_native.cpp b/WebUI/backend/native/secondo_native.cpp new file mode 100644 index 000000000..1b8631355 --- /dev/null +++ b/WebUI/backend/native/secondo_native.cpp @@ -0,0 +1,144 @@ +/* +---- +This file is part of SECONDO. + +Copyright (C) 2026, Faculty of Mathematics and Computer Science, +Database Systems for New Applications. + +SECONDO is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. +---- + +1 secondo\_native -- pybind11 wrapper over the SECONDO C++ client + +This module is a *thin* wrapper over the in-tree, prebuilt client-server +interface (~apis/api\_cpp/cs~, linking ~libsecondo.a~). It deliberately +does NOT reimplement any part of the SECONDO wire protocol: connecting, +binary nested-list decoding, framing and heartbeats all stay inside the +trusted C++ code that the rest of the system uses. + +The only thing crossing the language boundary is the nested list rendered +as *text* (~NestedList::ToString~). Turning that text into GeoJSON is done +in Python, where it is easy to fixture-test. + +*/ + +#include + +#include +#include + +#include "SecondoInterface.h" +#include "SecondoInterfaceCS.h" +#include "NestedList.h" +#include "NList.h" + +namespace py = pybind11; + +// One SECONDO client session. Not thread-safe on its own: callers must +// serialize access to a single Connection (the FastAPI layer keeps one +// Connection per browser session and guards it with a lock). +class Connection +{ + public: + Connection(const std::string& host, + const std::string& port, + const std::string& user, + const std::string& passwd, + const std::string& config) + { + si = new SecondoInterfaceCS(true, 0, false); + si->InitRTFlags(config); + + std::string errMsg; + const bool multiUser = true; // host/port take precedence over config + bool ok; + { + // Connecting blocks on network I/O and can hang if the SECONDO server is + // slow or wedged. Without releasing the GIL that would freeze the whole + // Python process (even endpoints that never touch SECONDO). + py::gil_scoped_release release; + ok = si->Initialize(user, passwd, host, port, config, "", + errMsg, multiUser); + } + if (!ok) { + delete si; + si = nullptr; + throw std::runtime_error("Cannot connect to SECONDO server at " + + host + ":" + port + " - " + errMsg); + } + nl = si->GetNestedList(); + NList::setNLRef(nl); + } + + ~Connection() { close(); } + + // Run one SECONDO command and return the result nested list as text. + // Raises RuntimeError on a SECONDO error (non-zero error code). + // + // SECONDO stores strings in Latin-1 (ISO-8859-1), so the nested-list text + // may contain bytes (e.g. 0xfc for 'u"'/umlaut) that are invalid UTF-8. + // pybind11's default std::string -> str conversion assumes UTF-8 and would + // raise UnicodeDecodeError on such results (Kinos, WFlaechen, ...). Decode + // Latin-1 explicitly: it is lossless for any byte and yields correct Unicode. + py::str secondo(const std::string& command) + { + if (!si) { + throw std::runtime_error("connection is closed"); + } + ListExpr res = nl->TheEmptyList(); + SecErrInfo err; + { + // The call blocks on network I/O; let other Python threads run. + py::gil_scoped_release release; + si->Secondo(command, res, err); + } + if (err.code != 0) { + throw std::runtime_error("SECONDO error " + std::to_string(err.code) + + " (pos " + std::to_string(err.pos) + "): " + + err.msg); + } + std::string out = nl->ToString(res); + return py::reinterpret_steal( + PyUnicode_DecodeLatin1(out.data(), out.size(), "replace")); + } + + void close() + { + if (si) { + { + // Terminate talks to the server too; same reasoning as above. + py::gil_scoped_release release; + si->Terminate(); + } + delete si; + si = nullptr; + nl = nullptr; + } + } + + private: + SecondoInterfaceCS* si = nullptr; + NestedList* nl = nullptr; +}; + +PYBIND11_MODULE(secondo_native, m) +{ + m.doc() = "Thin pybind11 wrapper over the SECONDO C++ client (libsecondo.a)"; + + py::class_(m, "Connection") + .def(py::init(), + py::arg("host"), + py::arg("port"), + py::arg("user") = "", + py::arg("passwd") = "", + py::arg("config") = "", + "Open a client-server connection to a running SecondoMonitor.") + .def("secondo", &Connection::secondo, py::arg("command"), + "Execute a SECONDO command; return the result nested list as text.") + .def("close", &Connection::close, "Close the connection."); +} diff --git a/WebUI/backend/requirements.txt b/WebUI/backend/requirements.txt new file mode 100644 index 000000000..0d9189fc0 --- /dev/null +++ b/WebUI/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +pybind11>=2.12 +pytest>=8.0 +httpx>=0.27 diff --git a/WebUI/backend/tests/fixtures/nested_lists.md b/WebUI/backend/tests/fixtures/nested_lists.md new file mode 100644 index 000000000..f1218fae8 --- /dev/null +++ b/WebUI/backend/tests/fixtures/nested_lists.md @@ -0,0 +1,36 @@ +# Captured nested-list samples (berlintest) + +Real `NestedList::ToString` output captured from a live SecondoMonitor via the +`secondo_native` binding. Used as fixtures for the nested-list parser and the +GeoJSON converter (Milestone 2+). + +## point (`query mehringdamm`) +``` +(point (9396.0 9871.0)) +``` + +## region (`query thecenter`) — truncated +``` +(region ((((4751.258065743784 11098.44273821416) (5174.969135626282 10226.09641786784) ...) ))) +``` +Structure: `(region ( face* ))`, `face = ( cycle* )`, `cycle = ( (x y)* )`. +First cycle of a face is the outer boundary, following cycles are holes. + +## line (`query BGrenzenLine`) — truncated +``` +(line ((-10849.0 1142.0 -10720.0 454.0) (-10849.0 1142.0 -10349.0 1402.0) ...)) +``` +Structure: `(line ( segment* ))`, `segment = (x1 y1 x2 y2)`. + +## relation (`query Trains feed head[1] project[Id, Line] consume`) +``` +((rel (tuple ((Id int) (Line int)))) ((1 1))) +``` +Structure: `((rel (tuple ( (attrname attrtype)* ))) ( tuple* ))`, +`tuple = ( attrvalue* )`. + +## scalars +``` +(int 13) +(inquiry (databases (BERLINTEST OPT SYMTRAJSMALL))) +``` diff --git a/WebUI/backend/tests/test_api.py b/WebUI/backend/tests/test_api.py new file mode 100644 index 000000000..1c5a737ae --- /dev/null +++ b/WebUI/backend/tests/test_api.py @@ -0,0 +1,113 @@ +"""API smoke tests that do NOT require a live SecondoMonitor. + +The SECONDO connection is faked so the FastAPI routing, session cookie handling +and error mapping can be tested in isolation. Real end-to-end verification +against a monitor is documented in WebUI/README.md. +""" +from __future__ import annotations + +import sys +import types + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + # Stub the native module before app import so config/session load cleanly. + fake = types.ModuleType("secondo_native") + + class FakeConnection: + def __init__(self, *_args, **_kwargs): + self.db = None + + def secondo(self, command: str) -> str: + if command.strip() == "list databases": + return "(inquiry (databases (BERLINTEST OPT)))" + if command.startswith("open database"): + return "()" + if command == "query mehringdamm": + return "(point (9396.0 9871.0))" + if command == "query umlaut": + # names decoded from Latin-1 arrive as ordinary Python str + return '((rel (tuple ((Name string) (Pos point)))) (("Stölpchensee" (1.0 2.0))))' + if command == "query boom": + raise ValueError("unexpected non-RuntimeError") + raise RuntimeError("SECONDO error 3 (pos 0): not evaluable") + + def close(self): + pass + + fake.Connection = FakeConnection + monkeypatch.setitem(sys.modules, "secondo_native", fake) + + from app.main import app # imported after the stub is in place + + return TestClient(app) + + +def test_health(client): + r = client.get("/api/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + + +def test_query_and_session_cookie(client): + r = client.post("/api/query", json={"command": "query mehringdamm"}) + assert r.status_code == 200 + assert r.json()["text"] == "(point (9396.0 9871.0))" + assert "secondo_sid" in r.cookies + + +def test_secondo_error_maps_to_400(client): + r = client.post("/api/query", json={"command": "query bogus"}) + assert r.status_code == 400 + assert "not evaluable" in r.json()["detail"] + + +def test_unexpected_error_still_returns_json(client, monkeypatch): + # A non-RuntimeError from the connection must not become a plain-text 500 + # (which would break the browser's response.json()). Patch the reference + # bound in app.session directly (module-level `import secondo_native`). + import app.session as session_mod + + class Boom: + def __init__(self, *a, **k): + pass + + def secondo(self, command): + raise ValueError("kaboom") + + def close(self): + pass + + import types + + fake = types.ModuleType("secondo_native") + fake.Connection = Boom + monkeypatch.setattr(session_mod, "secondo_native", fake) + + from app.main import app + + c = TestClient(app, raise_server_exceptions=False) # observe the JSON 500 + r = c.post("/api/query", json={"command": "anything"}) + assert r.status_code == 500 + assert r.headers["content-type"].startswith("application/json") + assert "ValueError" in r.json()["detail"] + + +def test_unicode_names_roundtrip(client): + r = client.post("/api/query", json={"command": "query umlaut"}) + assert r.status_code == 200 + body = r.json() + assert body["geojson"]["features"][0]["properties"]["Name"] == "Stölpchensee" + + +def test_databases_and_open_tracking(client): + client.post("/api/query", json={"command": "open database berlintest"}) + r = client.get("/api/databases") + assert r.status_code == 200 + body = r.json() + assert "BERLINTEST" in body["databases"] + assert body["open"] == "berlintest" diff --git a/WebUI/backend/tests/test_catalog.py b/WebUI/backend/tests/test_catalog.py new file mode 100644 index 000000000..d91bd3b01 --- /dev/null +++ b/WebUI/backend/tests/test_catalog.py @@ -0,0 +1,49 @@ +"""Tests for `list objects` parsing and the sline fix (Milestone 5). + +Inputs are real captures from SYMTRAJSMALL / berlintest. +""" +from __future__ import annotations + +from app.catalog import parse_objects +from app.geojson import to_geojson + +OBJECTS = ( + "(inquiry (objects (OBJECTS " + "(OBJECT Part () ((rel (tuple ((Moid int) (MP mpoint) (ML mlabels)))))) " + "(OBJECT EdgesExtDo () ((rel (tuple ((WayId longint) (Segment sline)))))) " + "(OBJECT thecenter () ((region))) " + "(OBJECT Orte () ((rel (tuple ((Ort string)))))) " + ")))" +) + + +def test_parse_objects_names_sorted(): + objs = parse_objects(OBJECTS) + names = [o["name"] for o in objs] + assert names == ["EdgesExtDo", "Orte", "Part", "thecenter"] + + +def test_parse_objects_kinds(): + by_name = {o["name"]: o for o in parse_objects(OBJECTS)} + assert by_name["Part"]["kind"] == "temporal" # has mpoint + assert by_name["EdgesExtDo"]["kind"] == "spatial" # has sline + assert by_name["thecenter"]["kind"] == "spatial" # region + assert by_name["Orte"]["kind"] == "other" # plain relation + assert by_name["Part"]["type"] == "rel" + + +def test_sline_wrapped_with_direction_flag(): + # sline value is ( (segments) bool ) -- must not be treated as segments. + text = "(sline (((7.4699 51.5132 7.4699 51.5124) (7.47 51.51 7.48 51.52)) TRUE))" + fc = to_geojson(text) + geom = fc["features"][0]["geometry"] + assert geom["type"] == "MultiLineString" + assert len(geom["coordinates"]) == 2 + assert geom["coordinates"][0][0] == [7.4699, 51.5132] + + +def test_plain_line_still_works(): + text = "(line ((0.0 0.0 1.0 1.0) (1.0 1.0 2.0 2.0)))" + geom = to_geojson(text)["features"][0]["geometry"] + assert geom["type"] == "MultiLineString" + assert len(geom["coordinates"]) == 2 diff --git a/WebUI/backend/tests/test_geojson.py b/WebUI/backend/tests/test_geojson.py new file mode 100644 index 000000000..b5b420e14 --- /dev/null +++ b/WebUI/backend/tests/test_geojson.py @@ -0,0 +1,104 @@ +"""Parser + GeoJSON converter tests. + +Inputs are real ``NestedList::ToString`` outputs captured from a live +SecondoMonitor (berlintest); see tests/fixtures/nested_lists.md. +""" +from __future__ import annotations + +from app.geojson import to_geojson +from app.nlparser import parse + +# --- parser --------------------------------------------------------------- + + +def test_parse_atoms_and_nesting(): + assert parse("(point (9396.0 9871.0))") == ["point", [9396.0, 9871.0]] + assert parse("(int 13)") == ["int", 13] + assert parse("(a (b c) TRUE)") == ["a", ["b", "c"], True] + + +def test_parse_string_and_symbol(): + assert parse('(x "hello world")') == ["x", "hello world"] + + +# --- point ---------------------------------------------------------------- + + +def test_point(): + fc = to_geojson("(point (9396.0 9871.0))") + assert fc["type"] == "FeatureCollection" + (feat,) = fc["features"] + assert feat["geometry"] == {"type": "Point", "coordinates": [9396.0, 9871.0]} + assert fc["bbox"] == [9396.0, 9871.0, 9396.0, 9871.0] + + +# --- line ----------------------------------------------------------------- + + +def test_line_segments_to_multilinestring(): + text = "(line ((-10849.0 1142.0 -10720.0 454.0) (-10720.0 454.0 -10688.0 243.0)))" + fc = to_geojson(text) + geom = fc["features"][0]["geometry"] + assert geom["type"] == "MultiLineString" + assert geom["coordinates"][0] == [[-10849.0, 1142.0], [-10720.0, 454.0]] + assert len(geom["coordinates"]) == 2 + + +# --- region --------------------------------------------------------------- + + +def test_region_face_with_hole(): + # one face: outer square + inner (hole) square + text = ( + "(region (" + "(((0.0 0.0) (10.0 0.0) (10.0 10.0) (0.0 10.0))" + " ((2.0 2.0) (4.0 2.0) (4.0 4.0) (2.0 4.0)))" + "))" + ) + fc = to_geojson(text) + geom = fc["features"][0]["geometry"] + assert geom["type"] == "MultiPolygon" + (poly,) = geom["coordinates"] + outer, hole = poly + assert outer[0] == outer[-1] # closed ring + assert len(outer) == 5 + assert hole[0] == [2.0, 2.0] + + +# --- rect ----------------------------------------------------------------- + + +def test_rect(): + fc = to_geojson("(rect (0.0 5.0 0.0 3.0))") + geom = fc["features"][0]["geometry"] + assert geom["type"] == "Polygon" + assert geom["coordinates"][0][0] == [0.0, 0.0] + assert geom["coordinates"][0][2] == [5.0, 3.0] + + +# --- relation ------------------------------------------------------------- + + +def test_relation_without_spatial_attr_yields_none(): + fc = to_geojson("((rel (tuple ((Id int) (Line int)))) ((1 1) (2 3)))") + assert fc is None + + +def test_relation_with_point_attr(): + text = ( + "((rel (tuple ((Name string) (Pos point)))) " + '(("a" (1.0 2.0)) ("b" (3.0 4.0))))' + ) + fc = to_geojson(text) + assert len(fc["features"]) == 2 + f0 = fc["features"][0] + assert f0["geometry"] == {"type": "Point", "coordinates": [1.0, 2.0]} + assert f0["properties"]["Name"] == "a" + assert f0["properties"]["_attr"] == "Pos" + + +# --- non-spatial ---------------------------------------------------------- + + +def test_scalar_has_no_geojson(): + assert to_geojson("(int 13)") is None diff --git a/WebUI/backend/tests/test_session.py b/WebUI/backend/tests/test_session.py new file mode 100644 index 000000000..6a8f472d7 --- /dev/null +++ b/WebUI/backend/tests/test_session.py @@ -0,0 +1,86 @@ +"""Session lifecycle tests. + +Every session holds a SECONDO connection and the server forks a process per +connection, so idle sessions must be reclaimed or they leak (observed as +orphaned SecondoBDB processes). +""" +from __future__ import annotations + +import asyncio +import types + +import pytest + + +@pytest.fixture() +def session_mod(monkeypatch): + import app.session as session_mod + + closed: list[str] = [] + + class FakeConnection: + def __init__(self, *_a, **_k): + pass + + def secondo(self, command: str) -> str: + return "()" + + def close(self) -> None: + closed.append("closed") + + fake = types.ModuleType("secondo_native") + fake.Connection = FakeConnection + monkeypatch.setattr(session_mod, "secondo_native", fake) + session_mod._closed = closed # expose for assertions + return session_mod + + +def test_idle_session_is_reaped(session_mod): + mgr = session_mod.SessionManager() + + async def scenario(): + session = await mgr.create() + session.last_used -= 10_000 # pretend it has been idle for ages + reaped = await mgr.reap(max_idle=60) + return reaped, mgr.count() + + reaped, remaining = asyncio.run(scenario()) + assert reaped == 1 + assert remaining == 0 + assert session_mod._closed == ["closed"] # connection really was closed + + +def test_recent_session_is_kept(session_mod): + mgr = session_mod.SessionManager() + + async def scenario(): + await mgr.create() + return await mgr.reap(max_idle=60), mgr.count() + + reaped, remaining = asyncio.run(scenario()) + assert reaped == 0 + assert remaining == 1 + + +def test_busy_session_is_never_reaped(session_mod): + mgr = session_mod.SessionManager() + + async def scenario(): + session = await mgr.create() + session.last_used -= 10_000 + async with session.lock: # simulate a command in flight + return await mgr.reap(max_idle=60) + + assert asyncio.run(scenario()) == 0 + + +def test_get_touches_session_so_it_survives(session_mod): + mgr = session_mod.SessionManager() + + async def scenario(): + session = await mgr.create() + session.last_used -= 10_000 + mgr.get(session.id) # an active client keeps it alive + return await mgr.reap(max_idle=60) + + assert asyncio.run(scenario()) == 0 diff --git a/WebUI/backend/tests/test_temporal.py b/WebUI/backend/tests/test_temporal.py new file mode 100644 index 000000000..5a322bbe0 --- /dev/null +++ b/WebUI/backend/tests/test_temporal.py @@ -0,0 +1,208 @@ +"""Tests for moving-object (mpoint) -> trips conversion. + +Inputs are in the real ``mpoint`` text form captured from berlintest Trains. +""" +from __future__ import annotations + +from app.convert import convert +from app.nlparser import parse +from app.temporal import from_tree, parse_instant + +# Two contiguous units of a single train's trip. +MPOINT = ( + "(mpoint (" + '(("2003-11-20-06:03" "2003-11-20-06:03:52.685" TRUE FALSE)' + " (13506.0 11159.0 13336.0 10785.0))" + '(("2003-11-20-06:03:52.685" "2003-11-20-06:04:08" TRUE FALSE)' + " (13336.0 10785.0 13287.0 10675.0))" + "))" +) + + +def test_parse_instant_formats(): + a = parse_instant("2003-11-20-06:03") + b = parse_instant("2003-11-20-06:03:52.685") + assert a is not None and b is not None + assert abs((b - a) - 52.685) < 1e-6 # 52.685 s apart + + +def test_mpoint_to_single_trip(): + payload = from_tree(parse(MPOINT)) + assert payload is not None + (trip,) = payload["trips"] + # 2 contiguous units -> 3 waypoints / 3 timestamps + assert trip["path"] == [ + [13506.0, 11159.0], + [13336.0, 10785.0], + [13287.0, 10675.0], + ] + assert len(trip["timestamps"]) == 3 + assert trip["timestamps"][0] < trip["timestamps"][1] < trip["timestamps"][2] + + +def test_time_domain_and_bbox(): + payload = from_tree(parse(MPOINT)) + ts = payload["trips"][0]["timestamps"] + assert payload["timeDomain"] == [ts[0], ts[-1]] + assert payload["bbox"] == [13287.0, 10675.0, 13506.0, 11159.0] + + +def test_temporal_gap_splits_into_two_trips(): + # second unit starts an hour after the first ends -> separate trips + gapped = ( + "(mpoint (" + '(("2003-11-20-06:00" "2003-11-20-06:01" TRUE FALSE) (0.0 0.0 1.0 1.0))' + '(("2003-11-20-07:00" "2003-11-20-07:01" TRUE FALSE) (5.0 5.0 6.0 6.0))' + "))" + ) + payload = from_tree(parse(gapped)) + assert len(payload["trips"]) == 2 + + +def test_relation_of_mpoint(): + rel = ( + "((rel (tuple ((Id int) (Trip mpoint)))) (" + '(1 ((' + '("2003-11-20-06:00" "2003-11-20-06:01" TRUE FALSE) (0.0 0.0 1.0 1.0))))' + "))" + ) + payload = from_tree(parse(rel)) + assert len(payload["trips"]) == 1 + assert payload["trips"][0]["properties"]["Id"] == 1 + assert payload["trips"][0]["properties"]["_attr"] == "Trip" + + +def test_convert_returns_both_channels_independently(): + geo, temp = convert("(point (1.0 2.0))") + assert geo is not None and temp is None + geo, temp = convert(MPOINT) + assert geo is None and temp is not None + + +# --- moving regions (Milestone 6) ------------------------------------------ + +# One unit, one face, one triangular cycle translating +10 east / +5 north. +MREGION = ( + "(mregion (" + '(("2003-11-20-06:00" "2003-11-20-07:00" TRUE TRUE)' + " ((((0.0 0.0 10.0 5.0) (4.0 0.0 14.0 5.0) (0.0 3.0 10.0 8.0))))" + ")))" +) + + +def test_mregion_units_and_vertices(): + payload = from_tree(parse(MREGION)) + assert payload is not None + assert payload["trips"] == [] + (region,) = payload["regions"] + (unit,) = region["units"] + (face,) = unit["faces"] + (cycle,) = face + # moving vertices keep both endpoints so the client can interpolate + assert cycle[0] == [0.0, 0.0, 10.0, 5.0] + assert len(cycle) == 3 + assert unit["interval"][0] < unit["interval"][1] + + +def test_mregion_bbox_spans_start_and_end_positions(): + payload = from_tree(parse(MREGION)) + # bbox must cover the region across its whole motion, not just t0 + assert payload["bbox"] == [0.0, 0.0, 14.0, 8.0] + + +def test_mregion_time_domain(): + payload = from_tree(parse(MREGION)) + unit = payload["regions"][0]["units"][0] + assert payload["timeDomain"] == [unit["interval"][0], unit["interval"][1]] + + +def test_relation_mixing_mpoint_and_mregion(): + rel = ( + "((rel (tuple ((Id int) (Trip mpoint) (Area mregion)))) (" + '(1 ((("2003-11-20-06:00" "2003-11-20-06:01" TRUE FALSE) (0.0 0.0 1.0 1.0)))' + ' ((("2003-11-20-06:00" "2003-11-20-07:00" TRUE TRUE)' + " ((((0.0 0.0 10.0 5.0) (4.0 0.0 14.0 5.0) (0.0 3.0 10.0 8.0))))))" + ")))" + ) + payload = from_tree(parse(rel)) + assert len(payload["trips"]) == 1 + assert len(payload["regions"]) == 1 + assert payload["regions"][0]["properties"]["_attr"] == "Area" + assert payload["trips"][0]["properties"]["_attr"] == "Trip" + + +def test_mpoint_payload_still_has_empty_regions(): + payload = from_tree(parse(MPOINT)) + assert payload["regions"] == [] + + +# --- scalar value plots: mreal / mint (Milestone 6) ------------------------- + + +def test_mreal_constant(): + payload = from_tree(parse('(mreal ((("2003-11-20" "2003-11-21" TRUE TRUE)' + " (0.0 0.0 5000.0 FALSE))))")) + (plot,) = payload["plots"] + assert plot["kind"] == "line" + assert plot["valueRange"] == [5000.0, 5000.0] # mreal5000 really is constant + assert all(v == 5000.0 for _, v in plot["series"]) + + +def test_mreal_sqrt_matches_secondo_ground_truth(): + # Real unit 0 of `distance(train7, mehringdamm)` from berlintest. SECONDO's + # own val(... atinstant ...) gives 11376.19382746268 at the start and + # 11177.34248379283 at the end -- t is measured in DAYS. + text = ( + '(mreal ((("2003-11-20-06:06" "2003-11-20-06:06:08.692" TRUE FALSE)' + " (3965029173458.559 -44978595490.10584 129417786.0 TRUE))))" + ) + plot = from_tree(parse(text))["plots"][0] + first_v = plot["series"][0][1] + last_v = plot["series"][-1][1] + assert abs(first_v - 11376.19382746268) < 1e-4 + assert abs(last_v - 11177.34248379283) < 1e-4 + + +def test_mreal_quadratic_is_sampled_not_just_endpoints(): + text = ( + '(mreal ((("2003-11-20-06:06" "2003-11-20-06:06:08.692" TRUE FALSE)' + " (3965029173458.559 -44978595490.10584 129417786.0 TRUE))))" + ) + plot = from_tree(parse(text))["plots"][0] + assert len(plot["series"]) > 2 # curved unit needs intermediate samples + + +def test_mint_is_a_step_series_and_handles_begin_of_time(): + text = ( + '(mint ((("begin of time" "2003-11-20-06:18:16.027" TRUE FALSE) 0)' + ' (("2003-11-20-06:18:16.027" "2003-11-20-06:35:29.353" TRUE FALSE) 1)' + ' (("2003-11-20-06:35:29.353" "2003-11-20-06:37:30.647" TRUE TRUE) 2)))' + ) + payload = from_tree(parse(text)) + (plot,) = payload["plots"] + assert plot["kind"] == "step" + assert plot["valueRange"] == [0.0, 2.0] + # the unbounded "begin of time" is resolved to the finite extent, not dropped + assert all(t == t and abs(t) != float("inf") for t, _ in plot["series"]) + assert len(plot["series"]) == 6 # two points per unit + + +def test_plot_only_object_has_no_bbox_but_has_a_time_domain(): + payload = from_tree(parse('(mreal ((("2003-11-20" "2003-11-21" TRUE TRUE)' + " (0.0 0.0 5000.0 FALSE))))")) + assert payload["bbox"] is None + assert payload["timeDomain"][0] < payload["timeDomain"][1] + assert payload["trips"] == [] and payload["regions"] == [] + + +def test_parse_instant_date_only(): + # mreal5000 uses date-only instants; these must not be rejected. + a = parse_instant("2003-11-20") + b = parse_instant("2003-11-21") + assert a is not None and b is not None + assert abs((b - a) - 86400) < 1e-6 + + +def test_parse_instant_rejects_unbounded_markers(): + assert parse_instant("begin of time") is None + assert parse_instant("end of time") is None diff --git a/WebUI/frontend/e2e/run.mjs b/WebUI/frontend/e2e/run.mjs new file mode 100644 index 000000000..5ec98cc6e --- /dev/null +++ b/WebUI/frontend/e2e/run.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Runs the end-to-end suite: every e2e/verify-*.mjs, sequentially, against a +// running stack (SecondoMonitor + FastAPI bridge + Vite dev server). +// +// node e2e/run.mjs # everything +// node e2e/run.mjs plots # only files whose name contains "plots" +// npm run e2e -- plots # same, via npm +// +// Each check script is a standalone program that exits 0 on success and +// non-zero on failure, so this runner keys off exit codes rather than parsing +// their output. Output from a failing script is echoed for diagnosis. +import { readdirSync } from "fs"; +import { spawnSync } from "child_process"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const filter = process.argv[2]; +const TIMEOUT_MS = 180_000; + +const BACKEND = process.env.WEBUI_API ?? "http://127.0.0.1:8000/api/health"; +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; + +async function reachable(url) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); + return res.ok; + } catch { + return false; + } +} + +// Fail fast with a useful message instead of a wall of navigation timeouts. +const [apiUp, webUp] = await Promise.all([reachable(BACKEND), reachable(URL)]); +if (!apiUp || !webUp) { + console.error("The e2e stack is not reachable:"); + if (!apiUp) console.error(` bridge ${BACKEND} DOWN (uvicorn app.main:app --port 8000)`); + if (!webUp) console.error(` web ${URL} DOWN (npm run dev)`); + console.error("Also make sure a SecondoMonitor is listening on port 1234."); + process.exit(2); +} + +const files = readdirSync(HERE) + .filter((f) => /^verify-.*\.mjs$/.test(f)) + .filter((f) => !filter || f.includes(filter)) + .sort(); + +if (files.length === 0) { + console.error(filter ? `No e2e checks match "${filter}".` : "No e2e checks found."); + process.exit(2); +} + +const name = (f) => f.replace(/^verify-/, "").replace(/\.mjs$/, ""); +const failed = []; +const started = Date.now(); + +for (const file of files) { + const t0 = Date.now(); + const run = spawnSync(process.execPath, [join(HERE, file)], { + encoding: "utf8", + timeout: TIMEOUT_MS, + env: process.env, + }); + const secs = ((Date.now() - t0) / 1000).toFixed(0); + const ok = run.status === 0; + const why = run.signal ? ` (killed: ${run.signal})` : ""; + console.log(`${ok ? "PASS" : "FAIL"} ${name(file).padEnd(16)} ${secs}s${why}`); + if (!ok) { + failed.push(name(file)); + const noise = /\[vite\]|DevTools|React DevTools|Download the React/; + const body = `${run.stdout ?? ""}${run.stderr ?? ""}` + .split("\n") + .filter((l) => l.trim() && !noise.test(l)) + .map((l) => ` ${l}`) + .join("\n"); + if (body) console.log(body); + } +} + +const total = ((Date.now() - started) / 1000).toFixed(0); +console.log( + `\n${files.length - failed.length}/${files.length} checks passed in ${total}s` + + (failed.length ? ` — failed: ${failed.join(", ")}` : "") +); +process.exit(failed.length === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-animation.mjs b/WebUI/frontend/e2e/verify-animation.mjs new file mode 100644 index 000000000..357bc27d8 --- /dev/null +++ b/WebUI/frontend/e2e/verify-animation.mjs @@ -0,0 +1,135 @@ +// Verifies moving-object animation: queries Trains, pauses the timeline, seeks +// to two different instants, and asserts (a) the TripsLayer draws and (b) the +// moving head's position changes between the two instants (i.e. it animates). +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: [ + "--no-sandbox", + "--use-gl=angle", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--enable-webgl", + "--window-size=1200,800", + ], +}); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1200, height: 800 }); + page.on("pageerror", (e) => console.log(" [pageerror]", e.message)); + + async function runCmd(command) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", command); + await page.keyboard.press("Enter"); + } + + // Move the timeline slider to a fraction of the domain (0..1) and let React + // apply it. The animation is paused first so the value sticks. + async function seekFraction(frac) { + await page.$eval( + ".tl-range", + (el, f) => { + const max = Number(el.max); + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + ).set; + nativeSetter.call(el, String(max * f)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }, + frac + ); + // A single thin trail needs a moment to be drawn; too short a wait here + // sampled an empty frame and made this test flaky. + await new Promise((r) => setTimeout(r, 1500)); + } + + // Centroid + count of the trail pixels. The trail is drawn in the *layer's* + // colour, so read that from the layer swatch rather than hard-coding a hue + // (the palette is a design decision and must not break this test). The faint + // full-trajectory context path blends far darker than the trail, so a tight + // tolerance around the full-intensity colour isolates the moving trail. + async function trailStats() { + return page.evaluate(() => { + const swatch = document.querySelector(".lp-swatch"); + const m = getComputedStyle(swatch).backgroundColor.match(/\d+/g); + const [tr, tg, tb] = m.map(Number); + const c = document.querySelector(".mapview canvas"); + const gl = + c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + const w = c.width, + h = c.height; + const px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + let n = 0, + sx = 0, + sy = 0; + for (let i = 0; i < px.length; i += 4) { + const a = px[i + 3]; + if ( + a > 10 && + Math.abs(px[i] - tr) < 60 && + Math.abs(px[i + 1] - tg) < 60 && + Math.abs(px[i + 2] - tb) < 60 + ) { + const p = i / 4; + sx += p % w; + sy += Math.floor(p / w); + n++; + } + } + return n ? { n, cx: sx / n, cy: sy / n } : { n: 0, cx: 0, cy: 0 }; + }); + } + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await page.waitForFunction( + () => document.querySelectorAll(".entry").length >= 1, + { timeout: 8000 } + ); + + await runCmd("query Trains feed head[3] project[Id, Trip] consume"); + await page.waitForSelector(".timeline", { timeout: 12000 }); + // Pause so seeks are stable. + await page.click(".tl-play"); + await new Promise((r) => setTimeout(r, 300)); + + await seekFraction(0.2); + const a = await trailStats(); + await page.screenshot({ path: `${OUT}/anim-t20.png` }); + + await seekFraction(0.8); + const b = await trailStats(); + await page.screenshot({ path: `${OUT}/anim-t80.png` }); + + const moved = Math.hypot(a.cx - b.cx, a.cy - b.cy); + console.log("t=20%:", JSON.stringify(a)); + console.log("t=80%:", JSON.stringify(b)); + console.log("head moved (px):", moved.toFixed(1)); + + const ok = a.n > 20 && b.n > 20 && moved > 15; + console.log(ok ? "PASS animation" : "FAIL animation"); + process.exitCode = ok ? 0 : 1; +} finally { + await browser.close(); +} diff --git a/WebUI/frontend/e2e/verify-catalog-basemap.mjs b/WebUI/frontend/e2e/verify-catalog-basemap.mjs new file mode 100644 index 000000000..5eeddb50d --- /dev/null +++ b/WebUI/frontend/e2e/verify-catalog-basemap.mjs @@ -0,0 +1,97 @@ +// Verifies Milestone 5: catalog DB/object browser, geographic MapLibre + OSM +// basemap for lon/lat data, and GeoJSON export. +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,800"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + // Count successful OSM tile loads to prove the basemap really renders. + let osmTiles = 0; + page.on("response", (r) => { + if (r.url().includes("tile.openstreetmap.org") && r.status() === 200) osmTiles++; + }); + + await page.goto(URL, { waitUntil: "networkidle0" }); + await new Promise((r) => setTimeout(r, 800)); + + // 1) Catalog lists databases. + const dbs = await page.$$eval(".cat-db", (els) => els.map((e) => e.textContent)); + check(dbs.includes("SYMTRAJSMALL"), `catalog lists databases (${dbs.join(",")})`); + + // 2) Open SYMTRAJSMALL from the catalog -> object list appears. + const target = await page.evaluateHandle(() => + [...document.querySelectorAll(".cat-db")].find((b) => b.textContent === "SYMTRAJSMALL")); + await target.asElement().click(); + await page.waitForSelector(".cat-obj", { timeout: 8000 }); + const objNames = await page.$$eval(".cat-obj .cat-oname", (els) => + els.map((e) => e.textContent)); + check(objNames.includes("EdgesExtDo"), + `objects listed after open (${objNames.length} objects)`); + + // 3) Click EdgesExtDo (lon/lat sline) -> geographic mode + OSM basemap. + const edges = await page.evaluateHandle(() => + [...document.querySelectorAll(".cat-obj")].find((b) => + b.textContent.includes("EdgesExtDo"))); + await edges.asElement().click(); + await page.waitForSelector(".maplibregl-map", { timeout: 10000 }); + check((await page.$eval(".mapview", (e) => e.dataset.geographic)) === "true", + "geographic mode activated (MapLibre basemap present)"); + + const hasMapLibre = await page.$(".maplibregl-map"); + check(!!hasMapLibre, "MapLibre basemap container present"); + + await new Promise((r) => setTimeout(r, 3500)); // let tiles load + check(osmTiles > 0, `OSM tiles loaded (${osmTiles})`); + await page.screenshot({ path: `${OUT}/map-geo.png` }); + + // 4) Export visible layers as GeoJSON (capture the blob). + await page.evaluate(() => { + window.__exports = []; + const orig = URL.createObjectURL; + URL.createObjectURL = (blob) => { + blob.text().then((t) => window.__exports.push(t)); + return orig(blob); + }; + }); + const exportBtn = await page.evaluateHandle(() => + [...document.querySelectorAll(".lp-clear")].find((b) => b.textContent === "export")); + await exportBtn.asElement().click(); + await new Promise((r) => setTimeout(r, 600)); + const exported = await page.evaluate(() => window.__exports[0] ?? null); + let ok = false; + if (exported) { + const fc = JSON.parse(exported); + ok = fc.type === "FeatureCollection" && fc.features.length > 0 && + !!fc.features[0].geometry; + } + check(ok, `export produced a GeoJSON FeatureCollection with features`); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-catalog-race.mjs b/WebUI/frontend/e2e/verify-catalog-race.mjs new file mode 100644 index 000000000..78d9d6646 --- /dev/null +++ b/WebUI/frontend/e2e/verify-catalog-race.mjs @@ -0,0 +1,79 @@ +// Verifies the catalog open/refresh race is fixed: clicking a database as soon +// as the page loads (before the session cookie settles) still fills the object +// list, and a loading spinner shows while objects are fetched. +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,800"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + // --- Test 1: fast first click still fills objects (run several times) --- + let filledRuns = 0; + const RUNS = 4; + for (let i = 0; i < RUNS; i++) { + const page = await browser.newPage(); + const client = await page.target().createCDPSession(); + await client.send("Network.clearBrowserCookies"); // force a fresh session + // Load without waiting for network idle, then click ASAP. + await page.goto(URL, { waitUntil: "domcontentloaded" }); + await page.waitForSelector(".cat-db", { timeout: 8000 }); + // Click SYMTRAJSMALL the instant the button exists (races the cookie). + await page.evaluate(() => { + const b = [...document.querySelectorAll(".cat-db")] + .find((x) => x.textContent.includes("SYMTRAJSMALL")); + b && b.click(); + }); + try { + await page.waitForSelector(".cat-obj", { timeout: 8000 }); + const n = await page.$$eval(".cat-obj", (els) => els.length); + if (n > 0) filledRuns++; + } catch { /* stayed empty */ } + await page.close(); + } + check(filledRuns === RUNS, + `fast first click fills objects on every run (${filledRuns}/${RUNS})`); + + // --- Test 2: loading spinner shows while objects load (throttled) --- + const page = await browser.newPage(); + await page.setRequestInterception(true); + page.on("request", async (r) => { + if (r.url().includes("/api/objects")) { + await new Promise((x) => setTimeout(x, 700)); + } + r.continue(); + }); + await page.goto(URL, { waitUntil: "networkidle0" }); + await page.waitForSelector(".cat-db", { timeout: 8000 }); + await page.evaluate(() => { + const b = [...document.querySelectorAll(".cat-db")] + .find((x) => x.textContent.includes("BERLINTEST")); + b && b.click(); + }); + let sawSpinner = false; + for (let i = 0; i < 25; i++) { + if (await page.$(".cat-spin")) { sawSpinner = true; break; } + await new Promise((r) => setTimeout(r, 40)); + } + check(sawSpinner, "loading spinner shown while objects load"); + await page.waitForSelector(".cat-obj", { timeout: 8000 }); + const finalN = await page.$$eval(".cat-obj", (els) => els.length); + check(finalN > 0, `objects fill after load (${finalN})`); + await page.close(); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-console.mjs b/WebUI/frontend/e2e/verify-console.mjs new file mode 100644 index 000000000..a6284aaa0 --- /dev/null +++ b/WebUI/frontend/e2e/verify-console.mjs @@ -0,0 +1,79 @@ +// Verifies the command console UX: focus retention after Enter, input clearing, +// and arrow-up/down history recall. +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1200,800"], +}); +const page = await browser.newPage(); +await page.setViewport({ width: 1200, height: 800 }); +page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + +const val = () => page.$eval(".input input", (el) => el.value); +const focused = () => page.evaluate(() => document.activeElement === document.querySelector(".input input")); + +async function type(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); +} +async function enter() { await page.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,400)); } + +await page.goto(URL, { waitUntil: "networkidle0" }); +let fails = 0; + +// Submit three commands. +await type("open database berlintest"); await enter(); +await type("query mehringdamm"); await enter(); +await type("query 3 + 4"); await enter(); + +// 1) Focus retained after Enter, input cleared. +const f1 = await focused(); const v1 = await val(); +console.log(`after Enter: focused=${f1} value="${v1}"`); +if (!f1) { console.log("FAIL: input lost focus after Enter"); fails++; } +if (v1 !== "") { console.log("FAIL: input not cleared"); fails++; } + +// 2) ArrowUp recalls most recent, then older. +await page.click(".input input"); +await page.keyboard.press("ArrowUp"); +const up1 = await val(); +await page.keyboard.press("ArrowUp"); +const up2 = await val(); +await page.keyboard.press("ArrowUp"); +const up3 = await val(); +console.log(`ArrowUp x3: "${up1}" | "${up2}" | "${up3}"`); +if (up1 !== "query 3 + 4") { console.log("FAIL: 1st ArrowUp"); fails++; } +if (up2 !== "query mehringdamm") { console.log("FAIL: 2nd ArrowUp"); fails++; } +if (up3 !== "open database berlintest") { console.log("FAIL: 3rd ArrowUp"); fails++; } + +// 3) ArrowDown walks back toward newest, then to empty draft. +await page.keyboard.press("ArrowDown"); +const dn1 = await val(); +await page.keyboard.press("ArrowDown"); +const dn2 = await val(); +await page.keyboard.press("ArrowDown"); +const dn3 = await val(); +console.log(`ArrowDown x3: "${dn1}" | "${dn2}" | "${dn3}"`); +if (dn1 !== "query mehringdamm") { console.log("FAIL: 1st ArrowDown"); fails++; } +if (dn2 !== "query 3 + 4") { console.log("FAIL: 2nd ArrowDown"); fails++; } +if (dn3 !== "") { console.log("FAIL: ArrowDown past newest should clear"); fails++; } + +// 4) Recall + submit works, and refocuses. +await page.keyboard.press("ArrowUp"); // "query 3 + 4" +await enter(); +const f2 = await focused(); const v2 = await val(); +console.log(`recall+submit: focused=${f2} value="${v2}"`); +if (!f2) { console.log("FAIL: focus after recall submit"); fails++; } + +await browser.close(); +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-layers.mjs b/WebUI/frontend/e2e/verify-layers.mjs new file mode 100644 index 000000000..da6518c54 --- /dev/null +++ b/WebUI/frontend/e2e/verify-layers.mjs @@ -0,0 +1,203 @@ +// Verifies the layers/styling/selection milestone: multiple layers accumulate, +// per-layer recolor updates the swatch, visibility toggling changes what's +// drawn, reordering swaps draw order, and clicking a feature shows its details. +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1200,800"], +}); + +let fails = 0; +const check = (cond, msg) => { console.log(`${cond ? "PASS" : "FAIL"} ${msg}`); if (!cond) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1200, height: 800 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 700)); + } + const drawn = () => + page.evaluate(() => { + const c = document.querySelector(".mapview canvas"); + const gl = c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + const px = new Uint8Array(c.width * c.height * 4); + gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px); + let n = 0; + for (let i = 0; i < px.length; i += 4) if (px[i + 3] > 10) n++; + return n; + }); + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await runCmd("query Flaechen feed head[5] consume"); // regions with Name + await runCmd("query BGrenzenLine"); // a line + + // 1) Two layers listed. + const items = await page.$$eval(".lp-item", (els) => els.length); + check(items === 2, `two layers listed (got ${items})`); + + // Panel shows topmost first; the line (added last) should be on top. + const firstName = await page.$eval(".lp-name", (el) => el.textContent); + check(/BGrenzenLine/.test(firstName ?? ""), `top layer is the line (got "${firstName}")`); + + // 2) Expand the top layer and recolor it -> swatch updates. + await page.click(".lp-name"); + await page.waitForSelector(".lp-style", { timeout: 3000 }); + const before = await page.$eval(".lp-swatch", (el) => el.style.background); + await page.$eval(".lp-style input[type=color]", (el) => { + const set = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, "value").set; + set.call(el, "#ff0000"); + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }); + await new Promise((r) => setTimeout(r, 300)); + const after = await page.$eval(".lp-swatch", (el) => el.style.background); + check(before !== after && /255|#ff0000|rgb\(255/.test(after), + `recolor updates swatch ("${before}" -> "${after}")`); + + // 3) Visibility toggle changes drawn pixels (hide the big regions layer). + const withAll = await drawn(); + // second checkbox in list = the regions layer (bottom item) + const boxes = await page.$$(".lp-item input[type=checkbox]"); + await boxes[boxes.length - 1].click(); // hide bottom (regions) + await new Promise((r) => setTimeout(r, 400)); + const withoutRegions = await drawn(); + check(withoutRegions < withAll - 500, + `hiding regions reduces drawn pixels (${withAll} -> ${withoutRegions})`); + await boxes[boxes.length - 1].click(); // show again + await new Promise((r) => setTimeout(r, 400)); + + // 4) Reorder: send the top (line) backward; a region name becomes first. + await page.click(".lp-name"); // collapse style so rows are simple + await new Promise((r) => setTimeout(r, 150)); + const sendBack = await page.$('.lp-item button[title="Send backward"]'); + await sendBack.click(); + await new Promise((r) => setTimeout(r, 250)); + const newFirst = await page.$eval(".lp-name", (el) => el.textContent); + check(!/BGrenzenLine/.test(newFirst ?? ""), + `reorder moved line down (top now "${newFirst}")`); + + // 5) Collapse: the panel folds to its header, freeing the map corner, and + // restores the same rows. Collapsing must not drop or hide any layer. + const panelH = () => page.$eval(".layers-panel", (el) => Math.round(el.getBoundingClientRect().height)); + // The header must not shift: hiding the export/clear buttons used to shrink + // it and bounce the title by a few px. + const titleY = () => page.$eval(".lp-collapse", (el) => el.getBoundingClientRect().top); + const headH = () => page.$eval(".lp-head", (el) => el.getBoundingClientRect().height); + const openH = await panelH(); + const openTitleY = await titleY(); + const openHeadH = await headH(); + const drawnOpen = await drawn(); + await page.click(".lp-collapse"); + await new Promise((r) => setTimeout(r, 250)); + check(Math.abs((await titleY()) - openTitleY) < 0.5, + `"Layers" title does not move when collapsing (${openTitleY.toFixed(1)} -> ${(await titleY()).toFixed(1)})`); + check(Math.abs((await headH()) - openHeadH) < 0.5, + `header keeps its height when collapsed (${openHeadH.toFixed(1)} -> ${(await headH()).toFixed(1)})`); + const shutH = await panelH(); + check(shutH < openH - 40, `collapse folds the panel to its header (${openH} -> ${shutH}px)`); + check((await page.$$(".lp-item")).length === 0, `collapsed panel hides the layer rows`); + // The map is untouched: collapsing is a panel affordance, not a layer edit. + check(Math.abs((await drawn()) - drawnOpen) < drawnOpen * 0.02, + `collapsing does not change what is drawn`); + await page.click(".lp-collapse"); + await new Promise((r) => setTimeout(r, 250)); + check((await page.$$(".lp-item")).length === 2, + `expanding restores both layer rows`); + + // 6) Selection: isolate the regions layer so it fills the viewport, then + // click a filled region -> details panel shows its Name property. + // NB: several buttons share `.lp-clear`, so target "clear" by its text -- + // a bare page.click(".lp-clear") hits "export" and silently leaves the + // layers in place, which then keeps the view fit to another layer's bounds. + const clearBtn = await page.evaluateHandle(() => + [...document.querySelectorAll(".lp-clear")].find((b) => b.textContent.trim() === "clear")); + await clearBtn.asElement().click(); + await page.waitForFunction(() => document.querySelectorAll(".lp-item").length === 0, + { timeout: 3000 }); + await runCmd("query Flaechen feed head[5] consume"); + await new Promise((r) => setTimeout(r, 600)); + + // Locate a filled pixel in the *interior* of a polygon. Requiring the + // neighbourhood to be filled too avoids landing on a thin line or an + // anti-aliased edge, where picking legitimately misses. Colour-agnostic: the + // layer palette is a design choice and must not break this test. + const findTarget = () => + page.evaluate(() => { + const c = document.querySelector(".mapview canvas"); + const rect = c.getBoundingClientRect(); + const gl = c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + const w = c.width, h = c.height; + const px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + const filled = (x, y) => { + const i = (y * w + x) * 4; + return px[i + 3] > 60 && Math.max(px[i], px[i + 1], px[i + 2]) > 60; + }; + const R = 4; // interior margin + for (let y = R; y < h - R; y++) { + for (let x = R; x < w - R; x++) { + if ( + filled(x, y) && filled(x + R, y) && filled(x - R, y) && + filled(x, y + R) && filled(x, y - R) + ) { + // readPixels is bottom-up; CSS is top-down. + return { + x: rect.left + (x / w) * rect.width, + y: rect.top + ((h - y) / h) * rect.height, + }; + } + } + } + return null; + }); + + // Snapshot -> click -> verify, and retry. Under full-suite load the regions + // aren't always drawn (and re-fitted) within the fixed wait above, so the + // first snapshot can find no interior pixel or an off frame and the pick + // misses. Re-snapshotting between attempts waits for a settled frame and a + // correct coordinate rather than flaking on a single early look. + let hasName = false; + for (let attempt = 0; attempt < 5 && !hasName; attempt++) { + const target = await findTarget(); + if (!target) { await new Promise((r) => setTimeout(r, 300)); continue; } + await page.mouse.click(target.x, target.y); + await new Promise((r) => setTimeout(r, 300)); + if (await page.$(".details")) { + hasName = await page.$$eval(".details .dk", (els) => + els.some((e) => e.textContent === "Name")); + } + } + check(hasName, `clicking a region shows its Name in details`); + + await page.screenshot({ path: `${OUT}/layers.png` }); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-map.mjs b/WebUI/frontend/e2e/verify-map.mjs new file mode 100644 index 000000000..81a3b260b --- /dev/null +++ b/WebUI/frontend/e2e/verify-map.mjs @@ -0,0 +1,103 @@ +// End-to-end map verification: drives the running app with a headless browser, +// runs spatial queries, and asserts the deck.gl WebGL canvas actually draws. +// +// Prereqs (all three must be up): +// - SecondoMonitor on :1234 with berlintest +// - FastAPI bridge on :8000 +// - Vite dev server on :5173 +// +// Usage: CHROMIUM=/usr/bin/chromium node e2e/verify-map.mjs +// Writes screenshots to e2e/out/. +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const CASES = [ + { name: "region", command: "query thecenter" }, + { name: "line", command: "query BGrenzenLine" }, + { name: "point", command: "query mehringdamm" }, +]; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: [ + "--no-sandbox", + "--use-gl=angle", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--enable-webgl", + "--window-size=1200,800", + ], +}); + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1200, height: 800 }); + page.on("pageerror", (e) => console.log(" [pageerror]", e.message)); + + async function runCmd(command) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", command); + await page.keyboard.press("Enter"); + } + + async function drawnPixels() { + return page.evaluate(() => { + const c = document.querySelector(".mapview canvas"); + if (!c) return -1; + const gl = + c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + if (!gl) return -1; + const px = new Uint8Array(c.width * c.height * 4); + gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px); + let drawn = 0; + for (let i = 0; i < px.length; i += 4) + if (px[i + 3] > 10 && px[i + 2] > 40) drawn++; + return drawn; + }); + } + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await page.waitForFunction( + () => document.querySelectorAll(".entry").length >= 1, + { timeout: 8000 } + ); + + let failures = 0; + let expected = 0; + for (const { name, command } of CASES) { + expected += 1; // each spatial query adds one ".geohint" marker + await runCmd(command); + await page.waitForFunction( + (n) => document.querySelectorAll(".geohint").length >= n, + { timeout: 10000 }, + expected + ); + await new Promise((r) => setTimeout(r, 1200)); + const drawn = await drawnPixels(); + await page.screenshot({ path: `${OUT}/map-${name}.png` }); + // A lone point is a ~4px dot (~80px); regions/lines are far larger. + const ok = drawn >= 30; + console.log(`${ok ? "PASS" : "FAIL"} ${name}: drawn=${drawn}`); + if (!ok) failures++; + } + + process.exitCode = failures === 0 ? 0 : 1; +} finally { + await browser.close(); +} diff --git a/WebUI/frontend/e2e/verify-mpoint-fit.mjs b/WebUI/frontend/e2e/verify-mpoint-fit.mjs new file mode 100644 index 000000000..c9cd8a9f2 --- /dev/null +++ b/WebUI/frontend/e2e/verify-mpoint-fit.mjs @@ -0,0 +1,104 @@ +// Regression: a moving-object (mpoint) layer must fit correctly under the +// BerlinMOD projection. The temporal bbox used to stay in raw BBBike +// coordinates while the trips were projected, so the view fell back to a +// whole-world Mercator view (reported for `train7`). +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const tileLon = (x, z) => (x / 2 ** z) * 360 - 180; +const tileLat = (y, z) => { + const n = Math.PI - (2 * Math.PI * y) / 2 ** z; + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +}; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,800"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + const tiles = []; + page.on("response", (r) => { + const m = r.url().match(/tile\.openstreetmap\.org\/(\d+)\/(\d+)\/(\d+)\.png/); + if (m && r.status() === 200) { + const z = +m[1], x = +m[2], y = +m[3]; + tiles.push({ z, lon: tileLon(x + 0.5, z), lat: tileLat(y + 0.5, z) }); + } + }); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 800)); + } + // A single mpoint draws only a faint context path, a thin trail and one dot, + // so use a lower brightness threshold than the multi-train tests. + const drawn = () => + page.evaluate(() => { + const c = document.querySelector(".mapview canvas"); + const gl = c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + const px = new Uint8Array(c.width * c.height * 4); + gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px); + let n = 0; + for (let i = 0; i < px.length; i += 4) + if (px[i + 3] > 10 && Math.max(px[i], px[i + 1], px[i + 2]) > 40) n++; + return n; + }); + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await runCmd("query train7"); // a bare mpoint: temporal-only layer + // Let the animation advance so the trail has actually been drawn. + await new Promise((r) => setTimeout(r, 2000)); + + // Flat first. + const flatPx = await drawn(); + check(flatPx > 50, `mpoint visible in flat view (${flatPx})`); + + // Now the projection: must fit Berlin, not the globe. + await page.select(".projection-ctl select", "berlinmod"); + await page.waitForSelector(".maplibregl-map", { timeout: 8000 }); + await new Promise((r) => setTimeout(r, 3500)); + + check(tiles.length > 0, `OSM tiles loaded (${tiles.length})`); + const nearBerlin = tiles.some( + (t) => Math.abs(t.lon - 13.4) < 1.0 && Math.abs(t.lat - 52.5) < 1.0 + ); + check(nearBerlin, `mpoint basemap centered on Berlin, not the world`); + // A whole-world view would be zoom 0-3; a fitted city view is much closer in. + const maxZoom = Math.max(...tiles.map((t) => t.z)); + check(maxZoom >= 8, `zoomed to city level, not world (max tile z=${maxZoom})`); + + const geoPx = await drawn(); + check(geoPx > 50, `mpoint drawn under projection (${geoPx})`); + + await page.screenshot({ path: `${OUT}/mpoint-projected.png` }); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-mregion.mjs b/WebUI/frontend/e2e/verify-mregion.mjs new file mode 100644 index 000000000..a91fb9b15 --- /dev/null +++ b/WebUI/frontend/e2e/verify-mregion.mjs @@ -0,0 +1,101 @@ +// Verifies moving regions (mregion): the polygon is rebuilt at the current +// instant, so it visibly moves/changes as the timeline advances. +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,850"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 850 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 900)); + } + // Drawn-pixel count + centroid of the rendered region. + const shape = () => + page.evaluate(() => { + const c = document.querySelector(".mapview canvas"); + const gl = c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + const w = c.width, h = c.height; + const px = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); + let n = 0, sx = 0, sy = 0; + for (let i = 0; i < px.length; i += 4) { + if (px[i + 3] > 10 && Math.max(px[i], px[i + 1], px[i + 2]) > 40) { + const p = i / 4; sx += p % w; sy += Math.floor(p / w); n++; + } + } + return { n, cx: n ? sx / n : 0, cy: n ? sy / n : 0 }; + }); + const seek = async (frac) => { + await page.$eval(".tl-range", (el, f) => { + const set = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, "value").set; + set.call(el, String(Number(el.max) * f)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }, frac); + await new Promise((r) => setTimeout(r, 600)); + }; + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await runCmd("query mrain"); // a moving region (rain cloud drifting NE) + + await page.waitForSelector(".timeline", { timeout: 10000 }); + check(true, "mregion produces a timeline (temporal layer)"); + await page.click(".tl-play"); // pause for deterministic sampling + await new Promise((r) => setTimeout(r, 300)); + + await seek(0.1); + const a = await shape(); + await page.screenshot({ path: `${OUT}/mregion-t10.png` }); + check(a.n > 500, `moving region is drawn (${a.n} px)`); + + await seek(0.9); + const b = await shape(); + await page.screenshot({ path: `${OUT}/mregion-t90.png` }); + check(b.n > 500, `still drawn later in the domain (${b.n} px)`); + + const moved = Math.hypot(a.cx - b.cx, a.cy - b.cy); + check(moved > 20, + `region moves as time advances (centroid drift ${moved.toFixed(1)}px)`); + + // It must also work under the BerlinMOD projection (vertices projected). + await page.select(".projection-ctl select", "berlinmod"); + await page.waitForSelector(".maplibregl-map", { timeout: 8000 }); + await new Promise((r) => setTimeout(r, 2500)); + const geo = await shape(); + check(geo.n > 500, `moving region renders under BerlinMOD projection (${geo.n} px)`); + await page.screenshot({ path: `${OUT}/mregion-projected.png` }); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-plots.mjs b/WebUI/frontend/e2e/verify-plots.mjs new file mode 100644 index 000000000..55fb0aa4c --- /dev/null +++ b/WebUI/frontend/e2e/verify-plots.mjs @@ -0,0 +1,99 @@ +// Verifies mreal/mint value plots: they render as small multiples (one y-scale +// each, never a dual axis), the readout tracks the timeline cursor, and a +// plot-only object (no geometry) still works. +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,850"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 850 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 900)); + } + const seek = async (frac) => { + await page.$eval(".tl-range", (el, f) => { + const set = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, "value").set; + set.call(el, String(Number(el.max) * f)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }, frac); + await new Promise((r) => setTimeout(r, 400)); + }; + const values = () => page.$$eval(".plot-value", (els) => els.map((e) => e.textContent)); + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + + // A plot-only object: no geometry at all, just a value over time. + await runCmd("query mreal5000"); + await page.waitForSelector(".plot", { timeout: 8000 }); + check(true, "plot-only object (mreal, no geometry) renders a plot"); + const constVals = await values(); + check(constVals[0] === "5000", `mreal5000 reads 5000 (${constVals[0]})`); + + // A varying mreal + a mint on different scales -> small multiples. + await runCmd("query distance(train7, mehringdamm)"); + await runCmd("query noAtCenter"); + await new Promise((r) => setTimeout(r, 500)); + const plots = await page.$$eval(".plot", (els) => els.length); + check(plots === 3, `three measures -> three small multiples, not one axis (${plots})`); + const svgs = await page.$$eval(".plot-svg", (els) => els.length); + check(svgs === plots, `each measure has its own plot/y-scale (${svgs})`); + + // Each plot is labelled and swatched (identity never colour-alone). + const labels = await page.$$eval(".plot-label", (els) => els.map((e) => e.textContent)); + const swatches = await page.$$eval(".plot-swatch", (els) => els.length); + check(labels.every((l) => l && l.length > 0) && swatches === plots, + `every plot is directly labelled + swatched (${labels.join(", ")})`); + + // The readout must follow the timeline cursor. + await page.click(".tl-play"); // pause + await new Promise((r) => setTimeout(r, 300)); + await seek(0.05); + const early = await values(); + await seek(0.55); + const mid = await values(); + check(early.join("|") !== mid.join("|"), + `readouts track the timeline cursor (${early.join(",")} -> ${mid.join(",")})`); + // The constant series must NOT change, the varying one must. + check(early[0] === "5000" && mid[0] === "5000", `constant series stays constant`); + + await page.screenshot({ path: `${OUT}/plots.png` }); + + // Collapsing keeps the map clear. + await page.click(".plots-toggle"); + await new Promise((r) => setTimeout(r, 250)); + check((await page.$$(".plot")).length === 0, `plots collapse away`); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-projection.mjs b/WebUI/frontend/e2e/verify-projection.mjs new file mode 100644 index 000000000..e16a242fa --- /dev/null +++ b/WebUI/frontend/e2e/verify-projection.mjs @@ -0,0 +1,90 @@ +// Verifies the BerlinMOD projection: querying berlintest data and selecting the +// "BerlinMOD -> OSM" projection renders it on the OpenStreetMap basemap at the +// real Berlin location (checked via the tile coordinates actually requested). +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +// OSM tile -> lon/lat of tile center. +const tileLon = (x, z) => (x / 2 ** z) * 360 - 180; +const tileLat = (y, z) => { + const n = Math.PI - (2 * Math.PI * y) / 2 ** z; + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +}; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,800"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + const tileCenters = []; + page.on("response", (r) => { + const m = r.url().match(/tile\.openstreetmap\.org\/(\d+)\/(\d+)\/(\d+)\.png/); + if (m && r.status() === 200) { + const z = +m[1], x = +m[2], y = +m[3]; + tileCenters.push([tileLon(x + 0.5, z), tileLat(y + 0.5, z)]); + } + }); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 700)); + } + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await runCmd("query thecenter"); // a region in BBBike coordinates + + // Default: flat/Cartesian, no basemap. + const mode = () => page.$eval(".mapview", (e) => e.dataset.geographic); + check((await mode()) === "false", "flat by default (no basemap)"); + check(!(await page.$(".maplibregl-map")), "no MapLibre basemap while flat"); + + // Switch projection to BerlinMOD -> OSM. + await page.select(".projection-ctl select", "berlinmod"); + await page.waitForSelector(".maplibregl-map", { timeout: 8000 }); + const proj = await page.$eval(".mapview", (e) => e.dataset.projection); + check(proj === "berlinmod", `map switched to the BerlinMOD projection (${proj})`); + check((await mode()) === "true", "map is in geographic mode"); + check(!!(await page.$(".maplibregl-map")), "MapLibre basemap present"); + + await new Promise((r) => setTimeout(r, 3500)); // let tiles load + check(tileCenters.length > 0, `OSM tiles loaded (${tileCenters.length})`); + + // At least one requested tile must be centered near Berlin (~13.4E, 52.5N). + const nearBerlin = tileCenters.some( + ([lon, lat]) => Math.abs(lon - 13.4) < 1.0 && Math.abs(lat - 52.5) < 1.0 + ); + check(nearBerlin, + `basemap is over Berlin (sample tile: ${tileCenters[0]?.map((v) => v.toFixed(2)).join(",")})`); + + await page.screenshot({ path: `${OUT}/berlinmod-osm.png` }); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-remove-layer.mjs b/WebUI/frontend/e2e/verify-remove-layer.mjs new file mode 100644 index 000000000..566f3e897 --- /dev/null +++ b/WebUI/frontend/e2e/verify-remove-layer.mjs @@ -0,0 +1,61 @@ +// Regression: removing the last layer while a projection is active must not +// crash the map (fitGeographic used to destructure an undefined bbox). +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,800"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 700)); + } + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await runCmd("query thecenter"); + await page.select(".projection-ctl select", "berlinmod"); // projection active + await page.waitForSelector(".maplibregl-map", { timeout: 8000 }); + await new Promise((r) => setTimeout(r, 800)); + + // Remove the (only/last) layer via the ✕ button. + const removeBtn = await page.$(".lp-item .lp-x"); + await removeBtn.click(); + await new Promise((r) => setTimeout(r, 800)); + + const layerItems = await page.$$eval(".lp-item", (els) => els.length).catch(() => 0); + check(layerItems === 0, "last layer removed"); + const crash = errors.find((e) => /bbox|Symbol\.iterator|undefined/.test(e)); + check(!crash, `no crash removing last layer under projection (errors: ${errors.length ? errors.join(" | ") : "none"})`); + + // The app must still be alive: run another query and see it render. + await runCmd("query mehringdamm"); + await new Promise((r) => setTimeout(r, 600)); + const back = await page.$$eval(".lp-item", (els) => els.length).catch(() => 0); + check(back === 1, "app still works after removal (new query adds a layer)"); +} finally { + await browser.close(); +} + +console.log(fails === 0 ? "RESULT: PASS" : `RESULT: FAIL (${fails})`); +process.exit(fails === 0 ? 0 : 1); diff --git a/WebUI/frontend/e2e/verify-render-modes.mjs b/WebUI/frontend/e2e/verify-render-modes.mjs new file mode 100644 index 000000000..31ba1acec --- /dev/null +++ b/WebUI/frontend/e2e/verify-render-modes.mjs @@ -0,0 +1,141 @@ +// Verifies the render-mode + style polish: moving-object "positions" mode draws +// discrete dots (fewer pixels than the trail), pointRadius affects them, and +// internal _attr/_layer keys are hidden from the details panel. +import { createRequire } from "module"; +import { mkdirSync } from "fs"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const puppeteer = require("puppeteer-core"); +const HERE = dirname(fileURLToPath(import.meta.url)); +const OUT = `${HERE}/out`; +mkdirSync(OUT, { recursive: true }); + +const URL = process.env.WEBUI_URL ?? "http://127.0.0.1:5173/"; +const CHROMIUM = process.env.CHROMIUM ?? "/usr/bin/chromium"; + +const browser = await puppeteer.launch({ + executablePath: CHROMIUM, + headless: "new", + args: ["--no-sandbox", "--use-gl=angle", "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", "--window-size=1280,800"], +}); + +let fails = 0; +const check = (c, m) => { console.log(`${c ? "PASS" : "FAIL"} ${m}`); if (!c) fails++; }; + +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + page.on("pageerror", (e) => console.log("[pageerror]", e.message)); + + async function runCmd(cmd) { + await page.click(".input input"); + await page.$eval(".input input", (el) => (el.value = "")); + await page.type(".input input", cmd); + await page.keyboard.press("Enter"); + await new Promise((r) => setTimeout(r, 700)); + } + const drawn = () => + page.evaluate(() => { + const c = document.querySelector(".mapview canvas"); + const gl = c.getContext("webgl2", { preserveDrawingBuffer: true }) || + c.getContext("webgl", { preserveDrawingBuffer: true }); + const px = new Uint8Array(c.width * c.height * 4); + gl.readPixels(0, 0, c.width, c.height, gl.RGBA, gl.UNSIGNED_BYTE, px); + let n = 0; + for (let i = 0; i < px.length; i += 4) + if (px[i + 3] > 10 && Math.max(px[i], px[i + 1], px[i + 2]) > 70) n++; + return n; + }); + async function setMode(mode) { + await page.select(".lp-style select", mode); + await new Promise((r) => setTimeout(r, 500)); + } + + await page.goto(URL, { waitUntil: "networkidle0" }); + await runCmd("open database berlintest"); + await runCmd("query Trains feed head[10] project[Id, Trip] consume"); + await page.waitForSelector(".timeline", { timeout: 12000 }); + + // Pause and seek to mid-domain so measurements are stable. + await page.click(".tl-play"); + await new Promise((r) => setTimeout(r, 200)); + await page.$eval(".tl-range", (el) => { + const set = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, "value").set; + set.call(el, String(Number(el.max) * 0.5)); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); + await new Promise((r) => setTimeout(r, 400)); + + // Expand the layer style; the moving-mode setProjection(e.target.value as Projection)} + title="Coordinate projection for the map" + > + {(Object.keys(PROJECTION_LABEL) as Projection[]).map((p) => ( + + ))} + + + + { + const props = + object && + (object as { properties?: Record }).properties; + if (layerId && props) { + const layer = layers.find((l) => l.id === layerId); + setSelected({ + layerId, + layerName: layer?.name ?? layerId, + properties: props as Record, + }); + } else { + setSelected(null); + } + }} + /> + + + + {selected && ( +
+
+ {selected.layerName} + +
+ + + {Object.entries(selected.properties) + .filter(([k]) => !k.startsWith("_")) + .map(([k, v]) => ( + + + + + ))} + +
{k}{String(v)}
+
+ )} + + {domain && ( + setPlotsCollapsed((c) => !c)} + /> + )} + + {domain && ( + + )} + + + ); +} diff --git a/WebUI/frontend/src/api/client.ts b/WebUI/frontend/src/api/client.ts new file mode 100644 index 000000000..7f186bd0c --- /dev/null +++ b/WebUI/frontend/src/api/client.ts @@ -0,0 +1,126 @@ +// Thin client for the FastAPI bridge. Cookies (the session id) are sent +// automatically because requests are same-origin via the Vite proxy. + +export interface Trip { + path: [number, number][]; + timestamps: number[]; + properties: Record; +} + +/** A vertex of a moving region: [xStart, yStart, xEnd, yEnd] across the unit. */ +export type MovingVertex = [number, number, number, number]; + +export interface MovingRegionUnit { + interval: [number, number]; + /** face -> cycle (first outer, rest holes) -> moving vertices */ + faces: MovingVertex[][][]; +} + +export interface MovingRegion { + units: MovingRegionUnit[]; + properties: Record; +} + +/** A scalar moving value (mreal/mint/mbool) sampled over time. */ +export interface Plot { + label: string; + kind: "line" | "step"; + type: string; + /** [posixSeconds, value] pairs */ + series: [number, number][]; + valueRange: [number, number]; + timeDomain: [number, number]; +} + +export interface TemporalPayload { + trips: Trip[]; + regions: MovingRegion[]; + plots: Plot[]; + timeDomain: [number, number]; + bbox: [number, number, number, number] | null; +} + +export interface FeatureCollection { + type: "FeatureCollection"; + features: unknown[]; + bbox?: [number, number, number, number]; +} + +export interface QueryResponse { + text: string; + geojson: FeatureCollection | null; + temporal: TemporalPayload | null; +} + +// Parse a response body defensively: a failing backend may return a non-JSON +// body (e.g. a plain-text 500), which must surface as a readable error rather +// than a cryptic "JSON.parse: unexpected character" from res.json(). +async function parseResponse(res: Response): Promise { + const text = await res.text(); + let data: unknown; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { detail: text.slice(0, 500) || `Request failed (${res.status})` }; + } + if (!res.ok) { + const detail = (data as { detail?: string })?.detail; + throw new Error(detail ?? `Request failed (${res.status})`); + } + return data as T; +} + +// Serialize all API calls into a single in-order queue. The session cookie is +// minted by the backend on the first request; running requests concurrently +// before that cookie is set would create multiple sessions (and e.g. leave the +// object list querying a session where no database is open). One-at-a-time also +// matches SECONDO, which handles one command per connection anyway. +let chain: Promise = Promise.resolve(); +function enqueue(task: () => Promise): Promise { + const result = chain.then(task, task); + chain = result.then( + () => undefined, + () => undefined + ); + return result; +} + +async function post(path: string, body: unknown): Promise { + return enqueue(async () => { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify(body), + }); + return parseResponse(res); + }); +} + +async function get(path: string): Promise { + return enqueue(async () => { + const res = await fetch(path, { credentials: "same-origin" }); + return parseResponse(res); + }); +} + +export function runQuery(command: string): Promise { + return post("/api/query", { command }); +} + +export async function listDatabases(): Promise<{ databases: string[]; open: string | null }> { + return get("/api/databases"); +} + +export interface CatalogObject { + name: string; + type: string; + kind: "spatial" | "temporal" | "other"; +} + +export async function listObjects(): Promise<{ + objects: CatalogObject[]; + open: string | null; +}> { + return get("/api/objects"); +} diff --git a/WebUI/frontend/src/catalog/Catalog.tsx b/WebUI/frontend/src/catalog/Catalog.tsx new file mode 100644 index 000000000..38a9aaad5 --- /dev/null +++ b/WebUI/frontend/src/catalog/Catalog.tsx @@ -0,0 +1,161 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + listDatabases, + listObjects, + type CatalogObject, +} from "../api/client"; + +// A left-hand browser: pick a database to open, then click an object to query +// it. Mirrors the HoeseViewer's object list; the console still works alongside. +interface Props { + // Run a SECONDO command through the same session as the console, and report + // whether it succeeded so the catalog can refresh. + onRun: (command: string) => Promise; + // Bumped by the parent whenever a command that changes catalog state runs + // (open/close/create/delete database, or object creation) so we refresh. + refreshKey: number; + // The catalog owns the authoritative database state (it is what queries the + // backend); it reports it up so the rest of the UI doesn't fetch it twice. + onState?: (state: { open: string | null; databases: string[] }) => void; + // Collapse the whole catalog to a rail, giving the space to the map. + onCollapse?: () => void; +} + +const KIND_ICON: Record = { + spatial: "◆", + temporal: "◷", + other: "·", +}; + +export function Catalog({ onRun, refreshKey, onState, onCollapse }: Props) { + const [databases, setDatabases] = useState([]); + const [open, setOpen] = useState(null); + const [objects, setObjects] = useState([]); + const [filter, setFilter] = useState(""); + const [loadingObjects, setLoadingObjects] = useState(false); + const [pendingDb, setPendingDb] = useState(null); + // Monotonic id so a slower, stale refresh can't overwrite a newer one. + const seqRef = useRef(0); + // Held in a ref so `refresh` stays stable regardless of the callback identity. + const onStateRef = useRef(onState); + onStateRef.current = onState; + + const refresh = useCallback(async () => { + const seq = ++seqRef.current; + try { + const dbs = await listDatabases(); + if (seq !== seqRef.current) return; + setDatabases(dbs.databases); + setOpen(dbs.open); + onStateRef.current?.({ open: dbs.open, databases: dbs.databases }); + if (dbs.open) { + setLoadingObjects(true); + const objs = await listObjects(); + if (seq !== seqRef.current) return; + setObjects(objs.objects); + } else { + setObjects([]); + } + } catch { + /* backend not reachable yet */ + } finally { + if (seq === seqRef.current) setLoadingObjects(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh, refreshKey]); + + async function openDb(name: string) { + if (pendingDb) return; // ignore rapid repeat clicks while one is in flight + setPendingDb(name); + const lname = name.toLowerCase(); + try { + if (open && open !== lname) await onRun("close database"); + if (open !== lname) await onRun(`open database ${name}`); + await refresh(); + } finally { + setPendingDb(null); + } + } + + const shown = objects.filter((o) => + o.name.toLowerCase().includes(filter.toLowerCase()) + ); + const busy = loadingObjects || pendingDb !== null; + const shownDb = open ?? pendingDb; + + return ( +
+
+ databases + {onCollapse && ( + + )} +
+
+ {databases.map((db) => ( + + ))} +
+ + {shownDb && ( + <> +
+ objects in {shownDb} + {busy ? ( + + ) : ( + {objects.length} + )} +
+ {open && ( + setFilter(e.target.value)} + /> + )} +
    + {busy && objects.length === 0 && ( +
  • + loading objects… +
  • + )} + {shown.map((o) => ( +
  • + +
  • + ))} +
+ + )} +
+ ); +} diff --git a/WebUI/frontend/src/console/Console.tsx b/WebUI/frontend/src/console/Console.tsx new file mode 100644 index 000000000..345d15b1e --- /dev/null +++ b/WebUI/frontend/src/console/Console.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; + +export interface Entry { + command: string; + result?: string; + error?: string; + hasGeometry?: boolean; + hasMotion?: boolean; +} + +interface Props { + history: Entry[]; + busy: boolean; + openDb: string | null; + layout: "side" | "bottom"; + collapsed: boolean; + onToggleCollapse: () => void; + onToggleLayout: () => void; + onSubmit: (command: string) => Promise; +} + +export function Console({ + history, + busy, + openDb, + layout, + collapsed, + onToggleCollapse, + onToggleLayout, + onSubmit, +}: Props) { + const [command, setCommand] = useState(""); + const [commands, setCommands] = useState([]); + const [histIndex, setHistIndex] = useState(-1); + const bottom = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + bottom.current?.scrollIntoView({ behavior: "smooth" }); + }, [history]); + + function recall(text: string) { + setCommand(text); + requestAnimationFrame(() => { + const el = inputRef.current; + if (el) el.setSelectionRange(el.value.length, el.value.length); + }); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowUp") { + if (commands.length === 0) return; + e.preventDefault(); + const next = + histIndex === -1 ? commands.length - 1 : Math.max(0, histIndex - 1); + setHistIndex(next); + recall(commands[next]); + } else if (e.key === "ArrowDown") { + if (histIndex === -1) return; + e.preventDefault(); + const next = histIndex + 1; + if (next >= commands.length) { + setHistIndex(-1); + recall(""); + } else { + setHistIndex(next); + recall(commands[next]); + } + } + } + + async function submit(cmd: string) { + const trimmed = cmd.trim(); + if (!trimmed || busy) return; + setCommand(""); + setHistIndex(-1); + setCommands((c) => (c[c.length - 1] === trimmed ? c : [...c, trimmed])); + await onSubmit(trimmed); + inputRef.current?.focus(); + } + + return ( +
+
+ SECONDO + {/* The database list lives in the catalog panel; don't duplicate it. */} + {openDb ? `db: ${openDb}` : "no database open"} + + +
+ +
+ {history.map((e, i) => ( +
+
+ > {e.command} +
+ {e.hasGeometry &&
▸ rendered on map
} + {e.hasMotion &&
▸ animated on timeline
} + {e.result !== undefined && ( +
+                {e.result.length > 4000
+                  ? e.result.slice(0, 4000) + "\n… (truncated)"
+                  : e.result}
+              
+ )} + {e.error !== undefined &&
{e.error}
} +
+ ))} +
+
+ +
{ + ev.preventDefault(); + void submit(command); + }} + > + > + setCommand(e.target.value)} + onKeyDown={onKeyDown} + /> + +
+
+ ); +} diff --git a/WebUI/frontend/src/layers/LayersPanel.tsx b/WebUI/frontend/src/layers/LayersPanel.tsx new file mode 100644 index 000000000..56d53a89e --- /dev/null +++ b/WebUI/frontend/src/layers/LayersPanel.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import type { Layer, LayerStyle, RGB, TemporalMode } from "./useLayers"; +import { downloadGeoJSON } from "./exportGeoJSON"; + +const toHex = ([r, g, b]: RGB): string => + "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join(""); + +const fromHex = (h: string): RGB => [ + parseInt(h.slice(1, 3), 16), + parseInt(h.slice(3, 5), 16), + parseInt(h.slice(5, 7), 16), +]; + +interface Props { + layers: Layer[]; + onToggle: (id: string) => void; + onRemove: (id: string) => void; + onMove: (id: string, dir: "up" | "down") => void; + onStyle: (id: string, patch: Partial) => void; + onClear: () => void; +} + +export function LayersPanel({ + layers, + onToggle, + onRemove, + onMove, + onStyle, + onClear, +}: Props) { + const [expanded, setExpanded] = useState(null); + const [collapsed, setCollapsed] = useState(false); + + if (layers.length === 0) return null; + + // Show topmost draw layer first. + const ordered = [...layers].reverse(); + + return ( +
+
+ + + + + +
+ {!collapsed && ( +
    + {ordered.map((layer, idx) => { + const isTop = idx === 0; + const isBottom = idx === ordered.length - 1; + const open = expanded === layer.id; + return ( +
  • +
    + onToggle(layer.id)} + title="Toggle visibility" + /> + + + + + +
    + + {open && ( +
    + + + + + + {/* Trail/positions only apply to moving points. */} + {layer.temporal && layer.temporal.trips.length > 0 && ( + + )} +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/WebUI/frontend/src/layers/exportGeoJSON.ts b/WebUI/frontend/src/layers/exportGeoJSON.ts new file mode 100644 index 000000000..1f085ae0f --- /dev/null +++ b/WebUI/frontend/src/layers/exportGeoJSON.ts @@ -0,0 +1,54 @@ +import type { Layer } from "./useLayers"; + +// Build one GeoJSON FeatureCollection from the visible layers. Static layers +// contribute their features directly; moving-object layers contribute each +// trip as a LineString carrying its timestamps, so the export is self-contained. +export function buildExportFC(layers: Layer[]): { + type: "FeatureCollection"; + features: unknown[]; +} { + const features: unknown[] = []; + for (const l of layers) { + if (!l.visible) continue; + if (l.geojson) { + for (const f of l.geojson.features as Array<{ + properties?: Record; + }>) { + features.push({ + ...f, + properties: { ...(f.properties ?? {}), _layer: l.name }, + }); + } + } + if (l.temporal) { + for (const trip of l.temporal.trips) { + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: trip.path }, + properties: { + ...trip.properties, + _layer: l.name, + timestamps: trip.timestamps, + }, + }); + } + } + } + return { type: "FeatureCollection", features }; +} + +// Trigger a browser download of the visible layers as a .geojson file. +export function downloadGeoJSON(layers: Layer[]): void { + const fc = buildExportFC(layers); + const blob = new Blob([JSON.stringify(fc, null, 2)], { + type: "application/geo+json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "secondo-export.geojson"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/WebUI/frontend/src/layers/useLayers.ts b/WebUI/frontend/src/layers/useLayers.ts new file mode 100644 index 000000000..1cd23898e --- /dev/null +++ b/WebUI/frontend/src/layers/useLayers.ts @@ -0,0 +1,141 @@ +import { useCallback, useRef, useState } from "react"; +import type { FeatureCollection, TemporalPayload } from "../api/client"; + +export type RGB = [number, number, number]; + +export type TemporalMode = "trail" | "points" | "both"; + +export interface LayerStyle { + color: RGB; + opacity: number; // 0..1 + pointRadius: number; // px (also the moving-object position-dot radius) + lineWidth: number; // px (also the moving-object trail width) + filled: boolean; + temporalMode: TemporalMode; // how moving objects render +} + +export interface Layer { + id: string; + name: string; + command: string; + geojson: FeatureCollection | null; + temporal: TemporalPayload | null; + visible: boolean; + style: LayerStyle; +} + +export interface Selection { + layerId: string; + layerName: string; + properties: Record; +} + +// Categorical palette, assigned in fixed order (never cycled by rank) so a +// layer keeps its colour as others come and go. These are the dark-surface +// steps of a validated categorical theme -- they pass the lightness-band, +// chroma-floor, CVD-separation and contrast checks against a dark chart +// surface, and being mid-dark they also read against the light OSM basemap. +// The one adjacent pair below CVD 12 (green/yellow) is always accompanied by +// secondary encoding: a swatch, the layer name and direct labels. +const PALETTE: RGB[] = [ + [57, 135, 229], // blue #3987e5 + [25, 158, 112], // aqua #199e70 + [201, 133, 0], // yellow #c98500 + [0, 131, 0], // green #008300 + [144, 133, 233], // violet #9085e9 + [230, 103, 103], // red #e66767 + [213, 81, 129], // magenta #d55181 + [217, 89, 38], // orange #d95926 +]; + +function deriveName(command: string): string { + const s = command.replace(/^\s*query\s+/i, "").trim(); + return s.length > 30 ? s.slice(0, 30) + "…" : s; +} + +export function useLayers() { + const [layers, setLayers] = useState([]); + const [selected, setSelected] = useState(null); + const idRef = useRef(0); + + const add = useCallback( + ( + command: string, + geojson: FeatureCollection | null, + temporal: TemporalPayload | null + ) => { + idRef.current += 1; + const id = `layer${idRef.current}`; + setLayers((prev) => { + const color = PALETTE[prev.length % PALETTE.length]; + const layer: Layer = { + id, + name: deriveName(command), + command, + geojson, + temporal, + visible: true, + style: { + color, + opacity: 0.85, + pointRadius: 4, + lineWidth: 1.5, + filled: true, + temporalMode: "both", + }, + }; + return [...prev, layer]; + }); + }, + [] + ); + + const remove = useCallback((id: string) => { + setLayers((prev) => prev.filter((l) => l.id !== id)); + setSelected((s) => (s?.layerId === id ? null : s)); + }, []); + + const toggle = useCallback((id: string) => { + setLayers((prev) => + prev.map((l) => (l.id === id ? { ...l, visible: !l.visible } : l)) + ); + }, []); + + // Move a layer up (toward the top of the draw order) or down. + const move = useCallback((id: string, dir: "up" | "down") => { + setLayers((prev) => { + const i = prev.findIndex((l) => l.id === id); + if (i < 0) return prev; + const j = dir === "up" ? i + 1 : i - 1; // higher index = drawn on top + if (j < 0 || j >= prev.length) return prev; + const next = [...prev]; + [next[i], next[j]] = [next[j], next[i]]; + return next; + }); + }, []); + + const setStyle = useCallback((id: string, patch: Partial) => { + setLayers((prev) => + prev.map((l) => + l.id === id ? { ...l, style: { ...l.style, ...patch } } : l + ) + ); + }, []); + + const clear = useCallback(() => { + setLayers([]); + setSelected(null); + }, []); + + return { + layers, + add, + remove, + toggle, + move, + setStyle, + clear, + selected, + setSelected, + }; +} diff --git a/WebUI/frontend/src/main.tsx b/WebUI/frontend/src/main.tsx new file mode 100644 index 000000000..39fb1c958 --- /dev/null +++ b/WebUI/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/WebUI/frontend/src/map/MapView.tsx b/WebUI/frontend/src/map/MapView.tsx new file mode 100644 index 000000000..5c016a23f --- /dev/null +++ b/WebUI/frontend/src/map/MapView.tsx @@ -0,0 +1,473 @@ +import { useMemo, useState } from "react"; +import DeckGL from "@deck.gl/react"; +import { + COORDINATE_SYSTEM, + MapView as DeckMapView, + OrthographicView, + WebMercatorViewport, +} from "@deck.gl/core"; +import type { PickingInfo } from "@deck.gl/core"; +import { + GeoJsonLayer, + PathLayer, + PolygonLayer, + ScatterplotLayer, +} from "@deck.gl/layers"; +import { TripsLayer } from "@deck.gl/geo-layers"; +import { Map as BaseMap } from "react-map-gl/maplibre"; +import "maplibre-gl/dist/maplibre-gl.css"; +import type { MovingRegion, Trip } from "../api/client"; +import type { Layer } from "../layers/useLayers"; +import { + projectBBox, + projectGeoJSON, + projectRegions, + projectTrips, + type Projection, +} from "./projection"; + +type BBox = [number, number, number, number]; + +const orthoView = new OrthographicView({ id: "ortho", flipY: false }); +const geoView = new DeckMapView({ id: "geo", repeat: true }); + +// Raster OpenStreetMap basemap (no external style file / API key needed). +const OSM_STYLE = { + version: 8 as const, + sources: { + osm: { + type: "raster" as const, + tiles: ["https://a.tile.openstreetmap.org/{z}/{x}/{y}.png"], + tileSize: 256, + attribution: "© OpenStreetMap contributors", + }, + }, + layers: [{ id: "osm", type: "raster" as const, source: "osm" }], +}; + +// A bbox is geographic when it fits within valid lon/lat ranges. SECONDO's own +// coordinate systems (e.g. berlintest ~10000) fall outside and stay Cartesian. +function isGeographic(bbox: BBox | undefined): boolean { + if (!bbox) return false; + const [minx, miny, maxx, maxy] = bbox; + return ( + minx >= -180 && maxx <= 180 && miny >= -90 && maxy <= 90 && + // reject a degenerate 0-area bbox around (0,0) + !(minx === 0 && miny === 0 && maxx === 0 && maxy === 0) + ); +} + +// Interpolate a moving object's position along its (normalized) path at time +// `t`, or null when it is not defined at t. Same piecewise-linear model the +// HoeseViewer uses; here it drives the exact-position dots. +function positionAt(trip: Trip, t: number): [number, number] | null { + const ts = trip.timestamps; + const path = trip.path; + if (ts.length < 2 || t < ts[0] || t > ts[ts.length - 1]) return null; + for (let i = 0; i < ts.length - 1; i++) { + if (t >= ts[i] && t <= ts[i + 1]) { + const span = ts[i + 1] - ts[i] || 1; + const f = (t - ts[i]) / span; + const [x0, y0] = path[i]; + const [x1, y1] = path[i + 1]; + return [x0 + f * (x1 - x0), y0 + f * (y1 - y0)]; + } + } + return null; +} + +// The polygon rings of a moving region at time `t`, or null when it is not +// defined then. Each vertex interpolates linearly across its unit -- the same +// model as moving points, and what the HoeseViewer's Dsplmovingregion does. +function facesAt(region: MovingRegion, t: number): [number, number][][][] | null { + for (const unit of region.units) { + const [t0, t1] = unit.interval; + if (t >= t0 && t <= t1) { + const f = t1 > t0 ? (t - t0) / (t1 - t0) : 0; + return unit.faces.map((face) => + face.map((cycle) => + cycle.map( + ([x0, y0, x1, y1]) => + [x0 + f * (x1 - x0), y0 + f * (y1 - y0)] as [number, number] + ) + ) + ); + } + } + return null; +} + +function unionBBox(layers: Layer[]): BBox | undefined { + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + for (const l of layers) { + for (const b of [l.geojson?.bbox, l.temporal?.bbox]) { + if (b) { + minx = Math.min(minx, b[0]); + miny = Math.min(miny, b[1]); + maxx = Math.max(maxx, b[2]); + maxy = Math.max(maxy, b[3]); + } + } + } + return minx === Infinity ? undefined : [minx, miny, maxx, maxy]; +} + +function fitCartesian(bbox: BBox | undefined) { + if (!bbox) return { target: [0, 0, 0] as [number, number, number], zoom: 0 }; + const [minx, miny, maxx, maxy] = bbox; + const spanX = Math.max(maxx - minx, 1e-6); + const spanY = Math.max(maxy - miny, 1e-6); + return { + target: [(minx + maxx) / 2, (miny + maxy) / 2, 0] as [number, number, number], + zoom: Math.log2(Math.min((900 * 0.9) / spanX, (700 * 0.9) / spanY)), + }; +} + +function fitGeographic(bbox: BBox | undefined) { + // No layers (e.g. the last one was just removed): show a neutral world view. + if (!bbox) return { longitude: 0, latitude: 0, zoom: 1 }; + // Defensive clamp: Web Mercator blows up outside these ranges, which would + // silently degrade into a whole-world view. + const clampLon = (v: number) => Math.min(180, Math.max(-180, v)); + const clampLat = (v: number) => Math.min(85, Math.max(-85, v)); + const [minx, miny, maxx, maxy] = [ + clampLon(bbox[0]), + clampLat(bbox[1]), + clampLon(bbox[2]), + clampLat(bbox[3]), + ]; + if (maxx - minx < 1e-9 || maxy - miny < 1e-9) { + return { longitude: (minx + maxx) / 2, latitude: (miny + maxy) / 2, zoom: 14 }; + } + const vp = new WebMercatorViewport({ width: 720, height: 800 }); + const { longitude, latitude, zoom } = vp.fitBounds( + [ + [minx, miny], + [maxx, maxy], + ], + { padding: 40 } + ); + return { longitude, latitude, zoom: Math.min(zoom, 18) }; +} + +interface Props { + layers: Layer[]; + globalT0: number; + currentTime: number; + projection: Projection; + onSelect: (layerId: string | null, object: unknown) => void; +} + +export function MapView({ + layers, + globalT0, + currentTime, + projection, + onSelect, +}: Props) { + // Apply the chosen projection to each layer's coordinates once (not per + // frame). With "berlinmod" the local BBBike coordinates become WGS84 lon/lat. + const layersToRender = useMemo(() => { + if (projection === "none") return layers; + return layers.map((l) => ({ + ...l, + geojson: l.geojson ? projectGeoJSON(l.geojson, projection) : null, + temporal: l.temporal + ? { + ...l.temporal, + trips: projectTrips(l.temporal.trips, projection), + regions: projectRegions(l.temporal.regions ?? [], projection), + // The bbox must be projected too, or the view fit would treat raw + // world coordinates as lon/lat (moving objects showed the globe). + // Plot-only objects (mreal/mint) have no geometry, hence no bbox. + bbox: l.temporal.bbox + ? projectBBox(l.temporal.bbox, projection) + : null, + } + : null, + })); + }, [layers, projection]); + + const bbox = useMemo(() => unionBBox(layersToRender), [layersToRender]); + const geographic = useMemo( + () => projection !== "none" || isGeographic(bbox), + [bbox, projection] + ); + // The view that fits the current data in the current projection. deck.gl's + // OrthographicViewState / MapViewState union is awkward to thread through + // controlled state, so this view state is intentionally loosely typed. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type VState = any; + const fit = useMemo( + () => (geographic ? fitGeographic(bbox) : fitCartesian(bbox)), + [bbox, geographic] + ); + + // Controlled view state: user pan/zoom updates it. We deliberately re-fit + // ONLY when a layer the map has never shown appears, or the projection + // changes (which moves the data to a whole new coordinate space). Editing a + // layer's style, toggling visibility or removing a layer must leave the view + // exactly where the user put it. The "adjust state during render" pattern + // keeps `views` and `viewState` in sync in the same render. + const [viewState, setViewState] = useState(fit); + const [fitState, setFitState] = useState<{ seen: string[]; projection: Projection }>( + { seen: [], projection } + ); + const unseen = layersToRender.filter((l) => !fitState.seen.includes(l.id)); + const projectionChanged = fitState.projection !== projection; + if (unseen.length > 0 || projectionChanged) { + setFitState({ + seen: [...fitState.seen, ...unseen.map((l) => l.id)], + projection, + }); + setViewState(fit); + } + + const zoomBy = (delta: number) => + setViewState((vs: VState) => ({ ...vs, zoom: (vs.zoom ?? 0) + delta })); + const recenter = () => setViewState(fit); + const coordinateSystem = geographic + ? COORDINATE_SYSTEM.LNGLAT + : COORDINATE_SYSTEM.CARTESIAN; + + const tripsById = useMemo(() => { + const m = new Map(); + for (const l of layersToRender) { + if (l.temporal) { + m.set( + l.id, + l.temporal.trips.map((tr) => ({ + ...tr, + timestamps: tr.timestamps.map((t) => t - globalT0), + })) + ); + } + } + return m; + }, [layersToRender, globalT0]); + + // Same normalisation for moving regions: unit intervals are absolute POSIX + // seconds, while `currentTime` runs from the shared t0. + const regionsById = useMemo(() => { + const m = new Map(); + for (const l of layersToRender) { + const regions = l.temporal?.regions; + if (regions && regions.length > 0) { + m.set( + l.id, + regions.map((r) => ({ + ...r, + units: r.units.map((u) => ({ + ...u, + interval: [u.interval[0] - globalT0, u.interval[1] - globalT0] as [ + number, + number, + ], + })), + })) + ); + } + } + return m; + }, [layersToRender, globalT0]); + + const combinedDuration = useMemo(() => { + let span = 0; + for (const l of layersToRender) + if (l.temporal) + span = Math.max(span, l.temporal.timeDomain[1] - l.temporal.timeDomain[0]); + return span; + }, [layersToRender]); + const trailLength = Math.max(combinedDuration * 0.08, 60); + + const deckLayers = []; + for (const layer of layersToRender) { + const [r, g, b] = layer.style.color; + const s = layer.style; + + if (layer.geojson) { + deckLayers.push( + new GeoJsonLayer({ + id: `${layer.id}-static`, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: layer.geojson as any, + coordinateSystem, + pickable: true, + autoHighlight: true, + highlightColor: [255, 255, 255, 90], + stroked: true, + filled: s.filled, + pointType: "circle", + getPointRadius: s.pointRadius, + pointRadiusUnits: "pixels", + getLineWidth: s.lineWidth, + lineWidthUnits: "pixels", + getFillColor: [r, g, b, Math.round(s.opacity * 150)], + getLineColor: [r, g, b, Math.round(s.opacity * 255)], + updateTriggers: { + getFillColor: [r, g, b, s.opacity, s.filled], + getLineColor: [r, g, b, s.opacity], + getPointRadius: s.pointRadius, + getLineWidth: s.lineWidth, + }, + }) + ); + } + + // Moving regions: rebuild the polygon at the current instant each frame. + const regions = regionsById.get(layer.id); + if (regions && regions.length > 0) { + const polys: { polygon: [number, number][][]; properties: unknown }[] = []; + for (const region of regions) { + const faces = facesAt(region, currentTime); + if (faces) { + for (const face of faces) { + polys.push({ polygon: face, properties: region.properties }); + } + } + } + if (polys.length > 0) { + deckLayers.push( + new PolygonLayer({ + id: `${layer.id}-mregion`, + data: polys, + coordinateSystem, + pickable: true, + filled: s.filled, + stroked: true, + getPolygon: (d: { polygon: [number, number][][] }) => d.polygon, + getFillColor: [r, g, b, Math.round(s.opacity * 130)], + getLineColor: [r, g, b, Math.round(s.opacity * 255)], + getLineWidth: s.lineWidth, + lineWidthUnits: "pixels", + updateTriggers: { + getFillColor: [r, g, b, s.opacity, s.filled], + getLineColor: [r, g, b, s.opacity], + getLineWidth: s.lineWidth, + }, + }) + ); + } + } + + const trips = tripsById.get(layer.id); + if (trips && trips.length > 0) { + const showTrail = s.temporalMode === "trail" || s.temporalMode === "both"; + const showPoints = s.temporalMode === "points" || s.temporalMode === "both"; + + if (showTrail) { + // Faint full trajectory for context. + deckLayers.push( + new PathLayer({ + id: `${layer.id}-context`, + data: trips, + coordinateSystem, + getPath: (d: Trip) => d.path, + getColor: [r, g, b, 55], + getWidth: 1, + widthUnits: "pixels", + widthMinPixels: 1, + updateTriggers: { getColor: [r, g, b] }, + }) + ); + // Animated trail; lineWidth controls its thickness. + deckLayers.push( + new TripsLayer({ + id: `${layer.id}-trips`, + data: trips, + coordinateSystem, + getPath: (d: Trip) => d.path, + getTimestamps: (d: { timestamps: number[] }) => d.timestamps, + getColor: [r, g, b], + opacity: s.opacity, + widthMinPixels: Math.max(s.lineWidth, 1), + capRounded: true, + jointRounded: true, + trailLength, + currentTime, + fadeTrail: true, + updateTriggers: { getColor: [r, g, b], getWidth: s.lineWidth }, + }) + ); + } + + if (showPoints) { + // Exact current positions of each moving object; pointRadius controls + // the dot size. Recomputed each frame as currentTime advances. + const positions = []; + for (const trip of trips) { + const pos = positionAt(trip, currentTime); + if (pos) positions.push({ position: pos, properties: trip.properties }); + } + deckLayers.push( + new ScatterplotLayer({ + id: `${layer.id}-pos`, + data: positions, + coordinateSystem, + pickable: true, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [r, g, b], + getRadius: s.pointRadius, + radiusUnits: "pixels", + radiusMinPixels: s.pointRadius, + stroked: true, + lineWidthMinPixels: 1, + getLineColor: [255, 255, 255, 220], + updateTriggers: { getFillColor: [r, g, b], getRadius: s.pointRadius }, + }) + ); + } + } + } + + return ( + // The mode is already visible in the projection dropdown; expose it here as + // state (not a second label) so tests can assert it. +
+ setViewState(e.viewState)} + controller={true} + layers={deckLayers} + onClick={(info: PickingInfo) => { + if (info.object && info.layer) { + onSelect(info.layer.id.split("-")[0], info.object); + } else { + onSelect(null, null); + } + }} + getTooltip={({ object }) => { + const props = (object as { properties?: Record }) + ?.properties; + if (!props) return null; + const text = Object.entries(props) + .filter(([k]) => !k.startsWith("_")) // hide internal _attr/_layer + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + return text ? { text } : null; + }} + > + {geographic && } + +
+ + + +
+ {layers.length === 0 && ( +
Run a spatial or moving-object query
+ )} +
+ ); +} diff --git a/WebUI/frontend/src/map/projection.ts b/WebUI/frontend/src/map/projection.ts new file mode 100644 index 000000000..5f278a60e --- /dev/null +++ b/WebUI/frontend/src/map/projection.ts @@ -0,0 +1,115 @@ +import type { + FeatureCollection, + MovingRegion, + MovingVertex, + Trip, +} from "../api/client"; + +// Projections a user can apply to a dataset's world coordinates. +export type Projection = "none" | "berlinmod"; + +export const PROJECTION_LABEL: Record = { + none: "Flat (no map)", + berlinmod: "BerlinMOD → OSM", +}; + +type BBox = [number, number, number, number]; + +// BBBike (BerlinMOD / berlintest local) coordinates -> WGS84 lon/lat. +// Constants and formula taken verbatim from SECONDO's +// Algebras/Spatial/Berlin2WGS.cpp (which in turn is from the BBBike sources). +const X0 = -780761.760862528; +const X1 = 67978.2421158527; +const X2 = -2285.59137120724; +const Y0 = -5844741.03397902; +const Y1 = 1214.24447469596; +const Y2 = 111217.945663725; + +export function berlin2wgs(x: number, y: number): [number, number] { + const lon = ((x - X0) * Y2 - (y - Y0) * X2) / (X1 * Y2 - Y1 * X2); + const lat = ((x - X0) * Y1 - (y - Y0) * X1) / (X2 * Y1 - X1 * Y2); + return [lon, lat]; +} + +const PROJECTORS: Record [number, number]) | null> = { + none: null, + berlinmod: berlin2wgs, +}; + +// Recursively map the [x,y] leaves of a GeoJSON coordinates array. +type Coords = number[] | Coords[]; +function mapCoords(c: Coords, fn: (x: number, y: number) => [number, number]): Coords { + if (typeof c[0] === "number") { + return fn(c[0] as number, c[1] as number); + } + return (c as Coords[]).map((child) => mapCoords(child, fn)); +} + +// The transform is linear, so a rectangle maps to a parallelogram whose +// axis-aligned bounds are attained at the projected corners. +export function projectBBox(b: BBox, projection: Projection): BBox { + const fn = PROJECTORS[projection]; + if (!fn) return b; + const [minx, miny, maxx, maxy] = b; + const pts = [ + fn(minx, miny), + fn(maxx, miny), + fn(maxx, maxy), + fn(minx, maxy), + ]; + const xs = pts.map((p) => p[0]); + const ys = pts.map((p) => p[1]); + return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; +} + +export function projectGeoJSON( + fc: FeatureCollection, + projection: Projection +): FeatureCollection { + const fn = PROJECTORS[projection]; + if (!fn) return fc; + const features = (fc.features as Array<{ geometry: { coordinates: Coords } }>).map( + (f) => ({ + ...f, + geometry: { ...f.geometry, coordinates: mapCoords(f.geometry.coordinates, fn) }, + }) + ); + return { + ...fc, + features, + bbox: fc.bbox ? projectBBox(fc.bbox, projection) : undefined, + }; +} + +export function projectTrips(trips: Trip[], projection: Projection): Trip[] { + const fn = PROJECTORS[projection]; + if (!fn) return trips; + return trips.map((t) => ({ + ...t, + path: t.path.map(([x, y]) => fn(x, y)), + })); +} + +export function projectRegions( + regions: MovingRegion[], + projection: Projection +): MovingRegion[] { + const fn = PROJECTORS[projection]; + if (!fn) return regions; + // Both endpoints of every moving vertex must be projected. + return regions.map((r) => ({ + ...r, + units: r.units.map((u) => ({ + ...u, + faces: u.faces.map((face) => + face.map((cycle) => + cycle.map(([x0, y0, x1, y1]): MovingVertex => { + const [a, b] = fn(x0, y0); + const [c, d] = fn(x1, y1); + return [a, b, c, d]; + }) + ) + ), + })), + })); +} diff --git a/WebUI/frontend/src/plots/PlotPanel.tsx b/WebUI/frontend/src/plots/PlotPanel.tsx new file mode 100644 index 000000000..e9901a34b --- /dev/null +++ b/WebUI/frontend/src/plots/PlotPanel.tsx @@ -0,0 +1,129 @@ +import type { Plot } from "../api/client"; +import type { RGB } from "../layers/useLayers"; + +export interface PlotEntry { + plot: Plot; + color: RGB; + layerName: string; +} + +interface Props { + entries: PlotEntry[]; + /** Absolute POSIX seconds of the shared time domain start. */ + t0: number; + /** Seconds into the shared domain (the timeline cursor). */ + time: number; + duration: number; + collapsed: boolean; + onToggle: () => void; +} + +const W = 250; // plot width in px +const H = 40; // plot height in px + +const rgb = ([r, g, b]: RGB) => `rgb(${r},${g},${b})`; + +/** Value at time `t` (absolute seconds). Step series carry explicit points at + * each unit boundary, so plain linear interpolation is correct for both kinds. */ +function valueAt(series: [number, number][], t: number): number | null { + if (series.length === 0) return null; + if (t <= series[0][0]) return series[0][1]; + const last = series[series.length - 1]; + if (t >= last[0]) return last[1]; + for (let i = 0; i < series.length - 1; i++) { + const [ta, va] = series[i]; + const [tb, vb] = series[i + 1]; + if (t >= ta && t <= tb) { + if (tb === ta) return vb; + return va + ((t - ta) / (tb - ta)) * (vb - va); + } + } + return last[1]; +} + +function fmt(v: number): string { + if (Number.isInteger(v)) return String(v); + if (Math.abs(v) >= 100) return v.toFixed(0); + return v.toFixed(2); +} + +export function PlotPanel({ + entries, + t0, + time, + duration, + collapsed, + onToggle, +}: Props) { + if (entries.length === 0) return null; + const now = t0 + time; + + return ( +
+
+ values + +
+ + {!collapsed && + entries.map(({ plot, color, layerName }, i) => { + // Small multiples: each measure keeps its own y scale. Never a second + // y-axis on a shared plot. + const [lo, hi] = plot.valueRange; + const span = hi - lo || 1; + const x = (t: number) => + duration > 0 ? ((t - t0) / duration) * W : 0; + const y = (v: number) => H - ((v - lo) / span) * H; + const points = plot.series + .map(([t, v]) => `${x(t).toFixed(1)},${y(v).toFixed(1)}`) + .join(" "); + const cur = valueAt(plot.series, now); + const cx = x(now); + // A bare object's plot is labelled with its type ("mreal"), which is + // ambiguous once two are shown; name it after its layer instead. + const label = plot.label === plot.type ? layerName : plot.label; + + return ( +
+
+ {/* Identity is carried by the swatch + name, never colour alone, + and the text keeps its own ink colour. */} + + + {label} + + {cur === null ? "–" : fmt(cur)} +
+ + {/* recessive baseline */} + + + {/* cursor at the timeline instant */} + + {cur !== null && ( + + )} + +
+ {fmt(lo)} + {fmt(hi)} +
+
+ ); + })} +
+ ); +} diff --git a/WebUI/frontend/src/styles.css b/WebUI/frontend/src/styles.css new file mode 100644 index 000000000..711075f8f --- /dev/null +++ b/WebUI/frontend/src/styles.css @@ -0,0 +1,855 @@ +:root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: #0f1115; + color: #e6e6e6; +} + +.app { + display: grid; + height: 100vh; +} + +/* Console docked to the left of the map (default). */ +.app.side { + grid-template-columns: var(--cat) 5px var(--con) 5px 1fr; + grid-template-rows: 1fr; +} +.app.side .catalog-pane { grid-column: 1; grid-row: 1; } +.app.side .split-a { grid-column: 2; grid-row: 1; cursor: col-resize; } +.app.side .console-pane { grid-column: 3; grid-row: 1; } +.app.side .split-b { grid-column: 4; grid-row: 1; cursor: col-resize; } +.app.side .map-pane { grid-column: 5; grid-row: 1; } + +/* Console docked under the map. */ +.app.bottom { + grid-template-columns: var(--cat) 5px 1fr; + grid-template-rows: 1fr 5px var(--conh); +} +.app.bottom .catalog-pane { grid-column: 1; grid-row: 1 / -1; } +.app.bottom .split-a { grid-column: 2; grid-row: 1 / -1; cursor: col-resize; } +.app.bottom .map-pane { grid-column: 3; grid-row: 1; } +.app.bottom .split-b { grid-column: 3; grid-row: 2; cursor: row-resize; } +.app.bottom .console-pane { grid-column: 3; grid-row: 3; } + +/* Collapsed console = header + input only, so the map takes the rest. */ +.app.bottom.con-collapsed { + grid-template-rows: 1fr 5px min-content; +} + +.console.collapsed .log { + display: none; +} + +/* Nothing to resize while a panel is collapsed. */ +.app.cat-collapsed .split-a, +.app.con-collapsed .split-b { + cursor: default; +} + +.rail-btn { + width: 100%; + height: 2.2rem; + background: none; + border: none; + border-bottom: 1px solid #262a33; + color: #9aa4b2; + cursor: pointer; + font-size: 0.85rem; +} + +.rail-btn:hover { + color: #e6e6e6; + background: #171a21; +} + +.cat-collapse { + margin-left: auto; + background: none; + border: none; + color: #6b7280; + cursor: pointer; + font-size: 0.85rem; + padding: 0 0.2rem; +} + +.cat-collapse:hover { + color: #e6e6e6; +} + +.split { + background: #1c2028; + transition: background 0.12s; +} + +.split:hover, +body.dragging .split { + background: #3b82f6; +} + +/* Keep the pointer consistent and prevent text selection while dragging. */ +body.dragging { + user-select: none; +} + +.catalog-pane { + overflow: hidden; +} + +.catalog { + height: 100%; + display: flex; + flex-direction: column; + padding: 0.5rem; + gap: 0.35rem; + overflow-y: auto; +} + +.cat-section { + display: flex; + align-items: center; + gap: 0.4rem; + color: #9aa4b2; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 0.3rem; +} + +.cat-count { + margin-left: auto; + background: #1c2028; + border-radius: 10px; + padding: 0 0.4rem; + font-size: 0.68rem; +} + +.cat-dbs { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.cat-db { + background: #171a21; + border: 1px solid #262a33; + color: #cbd5e1; + border-radius: 5px; + padding: 0.2rem 0.45rem; + font-size: 0.72rem; + cursor: pointer; +} + +.cat-db.active { + border-color: #60a5fa; + color: #fff; +} + +.cat-db.pending { + border-color: #60a5fa; +} + +.cat-db:disabled { + opacity: 0.55; + cursor: default; +} + +.cat-db { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.cat-spin { + width: 11px; + height: 11px; + border: 2px solid #3a3f4b; + border-top-color: #60a5fa; + border-radius: 50%; + display: inline-block; + animation: spin 0.7s linear infinite; + flex: none; +} + +.cat-loading { + display: flex; + align-items: center; + gap: 0.5rem; + color: #9aa4b2; + font-size: 0.75rem; + padding: 0.3rem; +} + +.cat-filter { + background: #171a21; + border: 1px solid #262a33; + color: #e6e6e6; + border-radius: 5px; + padding: 0.3rem 0.45rem; + font-size: 0.75rem; +} + +.cat-objs { + list-style: none; + margin: 0; + padding: 0; + overflow-y: auto; +} + +.cat-obj { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + background: none; + border: none; + color: #e6e6e6; + padding: 0.22rem 0.3rem; + border-radius: 4px; + cursor: pointer; + text-align: left; + font-size: 0.76rem; +} + +.cat-obj:hover { + background: #171a21; +} + +.cat-ic { + width: 1rem; + text-align: center; + color: #6b7280; +} + +.cat-obj.kind-spatial .cat-ic { + color: #7fb3e6; +} + +.cat-obj.kind-temporal .cat-ic { + color: #fd805d; +} + +.cat-oname { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, Menlo, monospace; +} + +.cat-otype { + color: #6b7280; + font-size: 0.68rem; +} + +.pane { + min-width: 0; + min-height: 0; + position: relative; +} + +.map-pane { + background: #0b0d11; +} + +.mapview { + position: absolute; + inset: 0; +} + +.map-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #6b7280; + pointer-events: none; +} + +.projection-ctl { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 3; +} + +.projection-ctl select { + background: rgba(23, 26, 33, 0.9); + color: #e6e6e6; + border: 1px solid #262a33; + border-radius: 6px; + padding: 0.25rem 0.45rem; + font-size: 0.75rem; + backdrop-filter: blur(4px); +} + +.zoom-ctl { + position: absolute; + right: 0.6rem; + top: 50%; + transform: translateY(-50%); + z-index: 3; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.zoom-ctl button { + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + background: rgba(23, 26, 33, 0.9); + color: #e6e6e6; + border: 1px solid #262a33; + border-radius: 6px; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + backdrop-filter: blur(4px); +} + +.zoom-ctl button:hover { + border-color: #60a5fa; +} + +.zoom-ctl .zoom-fit { + font-size: 0.95rem; + margin-top: 0.15rem; +} + +.loading { + position: absolute; + top: 0.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 4; + display: flex; + align-items: center; + gap: 0.5rem; + background: rgba(15, 17, 21, 0.92); + border: 1px solid #262a33; + color: #cbd5e1; + padding: 0.35rem 0.8rem; + border-radius: 999px; + font-size: 0.78rem; + backdrop-filter: blur(4px); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid #3a3f4b; + border-top-color: #60a5fa; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.lp-check select { + background: #171a21; + color: #e6e6e6; + border: 1px solid #3a3f4b; + border-radius: 4px; + margin-left: 0.4rem; + font-size: 0.72rem; +} + +.lp-actions { + display: flex; + gap: 0.35rem; +} + +.geohint { + color: #7fd1b9; + font-size: 0.75rem; + margin-bottom: 0.2rem; +} + +.layers-panel { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 2; + width: 240px; + background: rgba(15, 17, 21, 0.92); + border: 1px solid #262a33; + border-radius: 8px; + backdrop-filter: blur(4px); + font-size: 0.8rem; + overflow: hidden; +} + +.lp-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.45rem 0.6rem; + border-bottom: 1px solid #262a33; + color: #cbd5e1; + font-weight: 600; +} + +/* The export/clear buttons are taller than the title text and set the header's + height. Hiding them while collapsed keeps them in layout -- and out of hit + testing and the tab order -- so the header never changes height and the + title stays put. */ +.lp-actions.lp-hidden { + visibility: hidden; +} + +.lp-collapse { + display: flex; + align-items: center; + gap: 0.35rem; + background: none; + border: none; + padding: 0; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.lp-collapse:hover { + color: #e6e6e6; +} + +.lp-chevron { + display: inline-block; + width: 0.7rem; + color: #9aa4b2; + font-size: 0.7rem; +} + +.lp-count { + background: #262a33; + color: #9aa4b2; + border-radius: 999px; + padding: 0 0.35rem; + font-size: 0.7rem; + font-weight: 500; +} + +.lp-clear { + background: none; + border: 1px solid #3a3f4b; + color: #9aa4b2; + border-radius: 5px; + font-size: 0.72rem; + padding: 0.1rem 0.4rem; + cursor: pointer; +} + +.lp-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 45vh; + overflow-y: auto; +} + +.lp-item { + border-bottom: 1px solid #1c2028; +} + +.lp-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.5rem; +} + +.lp-swatch { + width: 12px; + height: 12px; + border-radius: 3px; + flex: none; +} + +.lp-name { + flex: 1; + text-align: left; + background: none; + border: none; + color: #e6e6e6; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, Menlo, monospace; + font-size: 0.76rem; +} + +.lp-mini { + background: none; + border: none; + color: #9aa4b2; + cursor: pointer; + padding: 0 0.15rem; + font-size: 0.8rem; +} + +.lp-mini:disabled { + opacity: 0.3; + cursor: default; +} + +.lp-x:hover { + color: #f87171; +} + +.lp-style { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.5rem; + align-items: center; + padding: 0.4rem 0.6rem 0.6rem; + background: #12151b; +} + +.lp-style label { + display: contents; + color: #9aa4b2; +} + +.lp-style input[type="range"] { + width: 100%; + accent-color: #60a5fa; +} + +.lp-style input[type="color"] { + width: 100%; + height: 1.4rem; + background: none; + border: 1px solid #3a3f4b; + border-radius: 4px; +} + +.lp-check { + grid-column: 1 / -1; + display: flex !important; + align-items: center; + gap: 0.4rem; +} + +.details { + position: absolute; + left: 0.75rem; + bottom: 4rem; + z-index: 2; + max-width: 320px; + max-height: 40vh; + overflow: auto; + background: rgba(15, 17, 21, 0.92); + border: 1px solid #262a33; + border-radius: 8px; + backdrop-filter: blur(4px); +} + +.details-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid #262a33; + color: #7fd1b9; + font-size: 0.8rem; + font-weight: 600; +} + +.details-head button { + background: none; + border: none; + color: #9aa4b2; + cursor: pointer; +} + +.details table { + border-collapse: collapse; + font-size: 0.76rem; + width: 100%; +} + +.details td { + padding: 0.2rem 0.6rem; + vertical-align: top; +} + +.details .dk { + color: #9aa4b2; + font-family: ui-monospace, Menlo, monospace; +} + +.details .dv { + color: #e6e6e6; + word-break: break-word; +} + +/* Value plots: small multiples, one per measure (never a shared/dual y-axis). */ +.plots { + position: absolute; + left: 0.75rem; + bottom: 4.2rem; + z-index: 3; + background: rgba(15, 17, 21, 0.92); + border: 1px solid #262a33; + border-radius: 8px; + backdrop-filter: blur(4px); + padding: 0.4rem 0.55rem 0.5rem; + max-height: 46vh; + overflow-y: auto; +} + +.plots-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: #9aa4b2; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.2rem; +} + +.plots-toggle { + background: none; + border: none; + color: #9aa4b2; + cursor: pointer; + font-size: 0.75rem; +} + +.plot + .plot { + margin-top: 0.5rem; + border-top: 1px solid #1c2028; + padding-top: 0.4rem; +} + +.plot-title { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; +} + +.plot-swatch { + width: 8px; + height: 8px; + border-radius: 2px; + flex: none; +} + +.plot-label { + color: #cbd5e1; /* text ink, not the series colour */ + font-family: ui-monospace, Menlo, monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plot-value { + margin-left: auto; + color: #e6e6e6; + font-family: ui-monospace, Menlo, monospace; + font-variant-numeric: tabular-nums; +} + +.plot-svg { + display: block; +} + +.plot-axis { + stroke: #262a33; + stroke-width: 1; +} + +.plot-cursor { + stroke: #6b7280; + stroke-width: 1; + stroke-dasharray: 2 2; +} + +.plot-range { + display: flex; + justify-content: space-between; + color: #6b7280; + font-size: 0.62rem; + font-variant-numeric: tabular-nums; +} + +.timeline { + position: absolute; + left: 0.75rem; + right: 0.75rem; + bottom: 0.75rem; + z-index: 2; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.75rem; + background: rgba(15, 17, 21, 0.9); + border: 1px solid #262a33; + border-radius: 8px; + backdrop-filter: blur(4px); +} + +.tl-play { + background: #fd805d; + color: #101216; + border: none; + width: 2rem; + height: 2rem; + border-radius: 50%; + cursor: pointer; + font-size: 0.75rem; + line-height: 1; +} + +.tl-clock { + font-family: ui-monospace, Menlo, monospace; + font-size: 0.78rem; + color: #cbd5e1; + min-width: 4.5rem; + text-align: center; +} + +.tl-range { + flex: 1; + accent-color: #fd805d; +} + +.tl-speed { + background: #171a21; + color: #e6e6e6; + border: 1px solid #262a33; + border-radius: 6px; + padding: 0.25rem 0.4rem; + font-size: 0.78rem; +} + +.console { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.console header { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid #262a33; +} + +.console header .db { + color: #7fd1b9; + font-size: 0.85rem; +} + +.dock-btn { + margin-left: auto; + background: #171a21; + border: 1px solid #262a33; + color: #9aa4b2; + border-radius: 5px; + padding: 0.15rem 0.45rem; + font-size: 0.72rem; + cursor: pointer; +} + +.dock-btn:hover { + border-color: #60a5fa; + color: #e6e6e6; +} + +.log { + flex: 1; + overflow-y: auto; + padding: 1rem; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 0.85rem; +} + +.entry { + margin-bottom: 0.9rem; +} + +.cmd { + color: #cbd5e1; + margin-bottom: 0.25rem; +} + +.prompt { + color: #60a5fa; + user-select: none; +} + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + padding: 0.5rem 0.75rem; + border-radius: 6px; + background: #171a21; +} + +pre.ok { + border-left: 3px solid #34d399; +} + +pre.err { + border-left: 3px solid #f87171; + color: #fca5a5; +} + +.input { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid #262a33; +} + +.input input { + flex: 1; + background: #171a21; + border: 1px solid #262a33; + color: #e6e6e6; + padding: 0.55rem 0.7rem; + border-radius: 6px; + font-family: ui-monospace, Menlo, monospace; + font-size: 0.85rem; +} + +.input button { + background: #2563eb; + color: white; + border: none; + padding: 0.55rem 1.1rem; + border-radius: 6px; + cursor: pointer; +} + +.input button:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/WebUI/frontend/src/timeline/Timeline.tsx b/WebUI/frontend/src/timeline/Timeline.tsx new file mode 100644 index 000000000..9254c1989 --- /dev/null +++ b/WebUI/frontend/src/timeline/Timeline.tsx @@ -0,0 +1,63 @@ +interface Props { + time: number; // seconds into the domain (0..duration) + duration: number; // seconds + t0: number; // POSIX seconds of domain start (for absolute labels) + playing: boolean; + speed: number; + onPlay: (playing: boolean) => void; + onSeek: (time: number) => void; + onSpeed: (speed: number) => void; +} + +const SPEEDS = [30, 60, 120, 300, 600]; + +function fmtClock(posixSeconds: number): string { + // Absolute wall-clock (UTC) HH:MM:SS of the domain instant. + return new Date(posixSeconds * 1000).toISOString().slice(11, 19); +} + +export function Timeline({ + time, + duration, + t0, + playing, + speed, + onPlay, + onSeek, + onSpeed, +}: Props) { + return ( +
+ + {fmtClock(t0 + time)} + onSeek(Number(e.target.value))} + /> + {fmtClock(t0 + duration)} + +
+ ); +} diff --git a/WebUI/frontend/src/timeline/useAnimator.ts b/WebUI/frontend/src/timeline/useAnimator.ts new file mode 100644 index 000000000..e85060ae4 --- /dev/null +++ b/WebUI/frontend/src/timeline/useAnimator.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from "react"; + +/** Drives a `time` value in [0, duration] via requestAnimationFrame. + * + * This replaces the HoeseViewer's Swing `javax.swing.Timer` animation loop + * (`AnimCtrlListener`): a single clock the moving-object layers read from. + * `speed` is simulated seconds advanced per real second. Playback loops. + */ +export function useAnimator(duration: number) { + const [time, setTime] = useState(0); + const [playing, setPlaying] = useState(false); + const [speed, setSpeed] = useState(120); + const raf = useRef(undefined); + const last = useRef(undefined); + + // Reset to the start whenever a new dataset (new duration) arrives. + useEffect(() => { + setTime(0); + setPlaying(duration > 0); + }, [duration]); + + useEffect(() => { + if (!playing || duration <= 0) return; + last.current = performance.now(); + const tick = (now: number) => { + const dt = (now - (last.current ?? now)) / 1000; + last.current = now; + setTime((t) => { + const nt = t + dt * speed; + return nt >= duration ? nt % duration : nt; // loop + }); + raf.current = requestAnimationFrame(tick); + }; + raf.current = requestAnimationFrame(tick); + return () => { + if (raf.current !== undefined) cancelAnimationFrame(raf.current); + }; + }, [playing, speed, duration]); + + return { time, setTime, playing, setPlaying, speed, setSpeed }; +} diff --git a/WebUI/frontend/tsconfig.json b/WebUI/frontend/tsconfig.json new file mode 100644 index 000000000..d10955520 --- /dev/null +++ b/WebUI/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/WebUI/frontend/tsconfig.tsbuildinfo b/WebUI/frontend/tsconfig.tsbuildinfo new file mode 100644 index 000000000..0fcdf728e --- /dev/null +++ b/WebUI/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/main.tsx","./src/api/client.ts","./src/catalog/Catalog.tsx","./src/console/Console.tsx","./src/layers/LayersPanel.tsx","./src/layers/exportGeoJSON.ts","./src/layers/useLayers.ts","./src/map/MapView.tsx","./src/map/projection.ts","./src/plots/PlotPanel.tsx","./src/timeline/Timeline.tsx","./src/timeline/useAnimator.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/WebUI/frontend/vite.config.ts b/WebUI/frontend/vite.config.ts new file mode 100644 index 000000000..a9418f52a --- /dev/null +++ b/WebUI/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Proxy /api to the FastAPI bridge so cookies stay same-origin in dev. +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + }, + }, +});