diff --git a/authentification-frontend/admin/admin.css b/authentification-frontend/admin/admin.css index 544f758..ba90e79 100644 --- a/authentification-frontend/admin/admin.css +++ b/authentification-frontend/admin/admin.css @@ -504,6 +504,89 @@ body { margin: 16px 0; } +/* ── DuckDB file manager (Edit Dataset modal) ── */ +.ds-files { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ds-file-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius, 6px); + background: var(--color-surface); + font-size: 13px; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.ds-file-row:hover { + border-color: rgba(99, 102, 241, 0.35); + background: rgba(99, 102, 241, 0.03); +} + +.ds-file-row.is-present { + border-color: rgba(22, 163, 74, 0.3); + background: var(--color-success-light); +} + +.ds-file-name { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + color: var(--color-text); +} + +.ds-file-name code { + font-size: 12px; + color: var(--color-text-secondary); +} + +.ds-file-check { + color: var(--color-success); + font-weight: 700; +} + +.ds-file-missing { + color: var(--color-text-secondary); +} + +.ds-file-action { + flex: 0 0 auto; + margin-left: auto; + padding: 5px 14px; + background: transparent; + border: 1px solid var(--color-primary); + border-radius: var(--btn-radius); + font-family: inherit; + font-size: 12px; + font-weight: 500; + color: var(--color-primary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.ds-file-action:hover { + background: var(--color-primary); + color: #fff; +} + +.ds-file-action:active { + transform: scale(.98); +} + +.ds-file-action:disabled { + color: var(--color-text-secondary); + border-color: var(--color-border); + background: transparent; + cursor: default; + box-shadow: none; +} + /* ── Toast ── */ .toast-container { diff --git a/authentification-frontend/admin/admin.js b/authentification-frontend/admin/admin.js index 8ededdf..4526e8e 100644 --- a/authentification-frontend/admin/admin.js +++ b/authentification-frontend/admin/admin.js @@ -375,8 +375,14 @@ function _renderDatasetRows(tbody, datasets, ownerFilter, ownerName) { // Delete buttons tbody.querySelectorAll("button[data-action=delete-ds]").forEach((btn) => { btn.addEventListener("click", async () => { - if (!confirm("Permanently delete this dataset and all its files?")) return; const dsid = btn.dataset.dsid; + const ds = _allDatasets.find((d) => String(d.id) === String(dsid)); + const dsName = ds?.name ? `"${ds.name}"` : "this dataset"; + if (!confirm( + `Permanently delete ${dsName}?\n\n` + + `This removes the dataset's entire folder on disk, including any ` + + `synthetic.duckdb / microcensus.duckdb files within it. This cannot be undone.` + )) return; const res = await datasetApi(`/admin/datasets/${dsid}`, { method: "DELETE" }); if (res.ok || res.status === 204) { await loadDatasets(_currentOwnerFilter, _currentOwnerName); @@ -500,6 +506,46 @@ async function createDataset() { // ── Edit Dataset Modal ─────────────────────────────────────────── +const DUCKDB_SOURCES = [ + { key: "synthetic", label: "Synthetic" }, + { key: "microcensus", label: "Microcensus" }, +]; + +let _pendingUploadCategory = null; + +function renderDatasetFiles(ds) { + const mgr = el("dsFilesManager"); + if (!mgr) return; + mgr.innerHTML = ""; + + // data_categories is computed live from disk by the backend + // (synthetic.duckdb -> "synthetic"), so it reflects what's actually present. + const categories = ds.data_categories || []; + for (const s of DUCKDB_SOURCES) { + const present = categories.includes(s.key); + const row = document.createElement("div"); + row.className = "ds-file-row" + (present ? " is-present" : ""); + row.innerHTML = present + ? ` ${s.label} ${s.key}.duckdb + ` + : `${s.label} + `; + mgr.appendChild(row); + } + + mgr.querySelectorAll("button[data-action=pick-duckdb]").forEach((b) => { + b.addEventListener("click", () => triggerDuckdbPicker(b.dataset.cat)); + }); +} + +function triggerDuckdbPicker(category) { + _pendingUploadCategory = category; + const input = el("dsHiddenFileInput"); + if (!input) return; + input.value = ""; + input.click(); +} + function openEditDatasetModal(ds) { el("editDsOriginalId").value = ds.id; el("editDsId").value = ds.id; @@ -508,9 +554,55 @@ function openEditDatasetModal(ds) { el("editDsStatus").value = ds.status || "inactive"; el("editDsPublic").checked = !!ds.is_public; + renderDatasetFiles(ds); + openModal("editDatasetModal"); } +async function uploadDuckdbFile(category, file) { + const dsid = el("editDsOriginalId").value; + if (!file.name.toLowerCase().endsWith(".duckdb")) { + showToast("Only .duckdb files are accepted"); + return; + } + + const mgr = el("dsFilesManager"); + const btn = mgr?.querySelector(`button[data-cat="${category}"]`); + const origText = btn?.textContent; + if (btn) { btn.disabled = true; btn.textContent = "Uploading…"; } + + const form = new FormData(); + form.append("file", file); + // Multipart upload — must NOT set Content-Type so the browser adds the boundary. + const url = `${CONFIG.DATASET_API_BASE}/admin/datasets/${dsid}/upload/${category}`; + const doFetch = () => + fetch(url, { method: "POST", credentials: "include", body: form }); + + try { + let res = await doFetch(); + if (res.status === 401) { + const ok = await refresh().catch(() => false); + if (ok) res = await doFetch(); + } + if (res.ok) { + const label = DUCKDB_SOURCES.find((s) => s.key === category)?.label || category; + showToast(`${label} DuckDB uploaded ✓`, "success"); + await loadDatasets(_currentOwnerFilter, _currentOwnerName); + // Re-render the manager from the refreshed flags (restores button state) + const updated = _allDatasets.find((d) => String(d.id) === String(dsid)); + if (updated) renderDatasetFiles(updated); + } else { + const err = await readJsonOrText(res); + showToast(err?.detail || "Upload failed"); + if (btn) { btn.disabled = false; btn.textContent = origText; } + } + } catch (err) { + console.warn("uploadDuckdb error:", err); + showToast("Upload failed"); + if (btn) { btn.disabled = false; btn.textContent = origText; } + } +} + async function saveDataset() { const originalId = el("editDsOriginalId").value; const newId = parseInt(el("editDsId").value, 10); @@ -654,4 +746,15 @@ function attachLogout() { if (saveDsBtn) { saveDsBtn.addEventListener("click", saveDataset); } + + const hiddenFile = el("dsHiddenFileInput"); + if (hiddenFile) { + hiddenFile.addEventListener("change", () => { + const file = hiddenFile.files?.[0]; + if (file && _pendingUploadCategory) { + uploadDuckdbFile(_pendingUploadCategory, file); + } + _pendingUploadCategory = null; + }); + } })(); diff --git a/authentification-frontend/admin/index.html b/authentification-frontend/admin/index.html index 14d0c80..b9be93f 100644 --- a/authentification-frontend/admin/index.html +++ b/authentification-frontend/admin/index.html @@ -196,6 +196,13 @@

Edit Dataset

+ +
+
+ +
+ +
); diff --git a/dataset-backend/public_routing.py b/dataset-backend/public_routing.py index 4e9c71f..87b8076 100644 --- a/dataset-backend/public_routing.py +++ b/dataset-backend/public_routing.py @@ -2,6 +2,7 @@ import logging import os +import shutil from pathlib import Path from AuthAPI import RequireUser, RequireAdminUser @@ -25,11 +26,11 @@ create_dataset_dirs, dataset_root, delete_dataset_dirs, + duckdb_path, list_data_categories, list_files, slugify, - validate_filename, - ALLOWED_EXTENSIONS, + DUCKDB_CATEGORIES, ) logger = logging.getLogger(__name__) @@ -182,44 +183,27 @@ async def delete_dataset( await db.commit() -# ── File upload ────────────────────────────────────────────────── +# ── DuckDB upload ───────────────────────────────────────────────── -@router.post("/datasets/{dataset_id}/upload/{category}") -async def upload_files( - dataset_id: int, - category: str, - files: list[UploadFile] = File(...), - user=Depends(RequireUser()), - db: AsyncSession = Depends(get_db), -): - if category not in ALLOWED_EXTENSIONS: +async def _store_duckdb(ds: Dataset, category: str, file: UploadFile, db: AsyncSession) -> dict: + """Stream an uploaded DuckDB file to ``/{category}.duckdb`` and refresh flags.""" + if category not in DUCKDB_CATEGORIES: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid category: {category}. Must be one of: {list(ALLOWED_EXTENSIONS.keys())}", + detail=f"invalid category: {category}. Must be one of: {list(DUCKDB_CATEGORIES)}", + ) + if Path(file.filename).suffix.lower() != ".duckdb": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="only .duckdb files are accepted", ) - ds = await require_dataset_access(dataset_id, db, user) - if ds.owner_id != user.id: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="only owner can upload") - - root = dataset_root(ds.owner_id, ds.id, ds.is_public) - cat_dir = root / category - cat_dir.mkdir(parents=True, exist_ok=True) + target = duckdb_path(ds.owner_id, ds.id, category, ds.is_public) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("wb") as out: + shutil.copyfileobj(file.file, out, length=1024 * 1024) - uploaded = [] - for f in files: - if not validate_filename(f.filename, category): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid filename: {f.filename}", - ) - target = cat_dir / Path(f.filename).name - content = await f.read() - target.write_bytes(content) - uploaded.append(f.filename) - - # Update completeness flags completeness = check_dataset_completeness(ds.owner_id, ds.id, ds.is_public) ds.has_synthetic = completeness["has_synthetic"] ds.has_microcensus = completeness["has_microcensus"] @@ -227,7 +211,21 @@ async def upload_files( ds.has_spider_db = completeness["has_spider_db"] await db.commit() - return {"uploaded": uploaded, "category": category} + return {"uploaded": target.name, "category": category} + + +@router.post("/datasets/{dataset_id}/upload/{category}") +async def upload_duckdb( + dataset_id: int, + category: str, + file: UploadFile = File(...), + user=Depends(RequireUser()), + db: AsyncSession = Depends(get_db), +): + ds = await require_dataset_access(dataset_id, db, user) + if ds.owner_id != user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="only owner can upload") + return await _store_duckdb(ds, category, file, db) @router.get("/datasets/{dataset_id}/files", response_model=list[FileListOut]) @@ -321,6 +319,21 @@ async def admin_delete_dataset( await db.commit() +@router.post("/admin/datasets/{dataset_id}/upload/{category}") +async def admin_upload_duckdb( + dataset_id: int, + category: str, + file: UploadFile = File(...), + admin=Depends(RequireAdminUser()), + db: AsyncSession = Depends(get_db), +): + """Upload a source DuckDB file to any dataset. Admin only.""" + ds = await db.get(Dataset, dataset_id) + if not ds: + raise HTTPException(status_code=404, detail="dataset not found") + return await _store_duckdb(ds, category, file, db) + + @router.post("/admin/datasets", response_model=dict, status_code=status.HTTP_201_CREATED) async def admin_create_dataset( body: AdminDatasetCreate, diff --git a/dataset-backend/storage.py b/dataset-backend/storage.py index ae5a4b0..4c61d10 100644 --- a/dataset-backend/storage.py +++ b/dataset-backend/storage.py @@ -30,6 +30,10 @@ "microcensus": {".parquet"}, } +# v2 layout: each source is a single DuckDB file at the dataset root +# (/synthetic.duckdb, /microcensus.duckdb). +DUCKDB_CATEGORIES = ("synthetic", "microcensus") + def slugify(name: str) -> str: """Convert a dataset name to a filesystem-safe slug.""" @@ -51,13 +55,22 @@ def dataset_root(owner_id: int, dataset_id: int, is_public: bool = False) -> Pat def create_dataset_dirs(owner_id: int, dataset_id: int, is_public: bool = False) -> Path: - """Create the directory structure for a new dataset. Returns root path.""" + """Create an empty root directory for a new dataset. Returns root path. + + The per-source DuckDB files (``synthetic.duckdb`` / ``microcensus.duckdb``) + are uploaded into this directory afterwards — we no longer pre-create the + legacy ``synthetic/`` and ``microcensus/`` subfolders. + """ root = dataset_root(owner_id, dataset_id, is_public) - (root / "synthetic").mkdir(parents=True, exist_ok=True) - (root / "microcensus").mkdir(parents=True, exist_ok=True) + root.mkdir(parents=True, exist_ok=True) return root +def duckdb_path(owner_id: int, dataset_id: int, category: str, is_public: bool = False) -> Path: + """Return the destination path for a source's DuckDB file at the dataset root.""" + return dataset_root(owner_id, dataset_id, is_public) / f"{category}.duckdb" + + def delete_dataset_dirs(owner_id: int, dataset_id: int, is_public: bool = False) -> None: """Remove the entire dataset directory tree.""" root = dataset_root(owner_id, dataset_id, is_public) @@ -86,19 +99,29 @@ def validate_filename(filename: str, category: str) -> bool: def check_dataset_completeness(owner_id: int, dataset_id: int, is_public: bool = False) -> dict[str, bool]: - """Check which data categories are populated.""" + """Check which data categories are populated. + + Recognises both layouts: the v2 per-source DuckDB files at the root + (``synthetic.duckdb`` / ``microcensus.duckdb``) and the legacy v1 + subdirectories (``synthetic/`` / ``microcensus/``). + """ root = dataset_root(owner_id, dataset_id, is_public) + syn_db = root / "synthetic.duckdb" + mc_db = root / "microcensus.duckdb" syn_dir = root / "synthetic" mc_dir = root / "microcensus" + jp_dir = root / "json_preview" - has_synthetic = syn_dir.exists() and any(syn_dir.iterdir()) - has_microcensus = mc_dir.exists() and any(mc_dir.iterdir()) - has_spider_db = (syn_dir / "spider.duckdb").exists() + has_synthetic = syn_db.exists() or (syn_dir.exists() and any(syn_dir.iterdir())) + has_microcensus = mc_db.exists() or (mc_dir.exists() and any(mc_dir.iterdir())) + has_spider_db = syn_db.exists() or (syn_dir / "spider.duckdb").exists() + has_json_preview = jp_dir.exists() and any(jp_dir.iterdir()) return { "has_synthetic": has_synthetic, "has_microcensus": has_microcensus, "has_spider_db": has_spider_db, + "has_json_preview": has_json_preview, } diff --git a/webmap-backend/main.py b/webmap-backend/main.py index 23e1222..a3221de 100644 --- a/webmap-backend/main.py +++ b/webmap-backend/main.py @@ -123,6 +123,11 @@ def _prewarm_caches() -> None: logger.info("prewarmed transit stops for %s", root) except Exception as exc: logger.warning("transit prewarm skipped for %s: %s", root, exc) + # NB: the per-line transit_routes index (providers/transit_routes.py) + # is intentionally NOT prewarmed — it parses the ~76 MB routes asset + # and would hold it in RAM for every dataset at startup. It builds + # lazily on the first line selection instead (one ~6 s parse per + # dataset per worker, then cached). finally: set_root_override(None) @@ -211,8 +216,10 @@ async def dispatch(self, request: Request, call_next): from providers.helpers import load_static_asset_bytes, resolve_canton_to_polygon_id _MERGED_SUFFIX = "_merged_segments.geojson" +_TRAFFIC_SUFFIX = "_link_traffic_volumes.json" _COUNTS_RE = _re.compile(r"transit/per_canton_counts/(.+)_counts\.json$") _STOPS_RE = _re.compile(r"transit/stops_by_canton/(.+)_stops\.geojson$") +_ROUTES_BY_LINE_RE = _re.compile(r"transit/routes/by_line/(.+)\.geojson$") def _canton_id_from(name: str) -> int | None: @@ -241,13 +248,51 @@ async def matsim_asset(dataset_id: int, asset_path: str, request: Request): cid = _canton_id_from(asset_path[: -len(_MERGED_SUFFIX)]) if cid is None: return JSONResponse({"error": "not found"}, status_code=404) - payload = await _asyncio.to_thread( - load_static_asset_bytes, "synthetic", f"merged_segments:{cid}" - ) + # Build the network from network_links so it carries modes/capacity/ + # length (the precomputed merged_segments blob is thin — link_id/ + # road_type/freespeed only — which blanks the Volumes car filter and + # breaks capacity line-width). Fall back to the static blob for older + # datasets that lack the network_links table. + from providers.network_geometry import merged_segments_geojson + payload = await _asyncio.to_thread(merged_segments_geojson, cid) + if payload is None: + payload = await _asyncio.to_thread( + load_static_asset_bytes, "synthetic", f"merged_segments:{cid}" + ) if payload is None: return JSONResponse({"error": "not found"}, status_code=404) return _Response(content=payload, media_type="application/geo+json") + # Per-link hourly car traffic volumes for the road "Volumes" module — + # derived from the link_speeds table (the old preprocessed CDN asset is + # gone). Returns [{link_id, hourly_avg_volumes:[24]}]. + if asset_path.endswith(_TRAFFIC_SUFFIX): + cid = _canton_id_from(asset_path[: -len(_TRAFFIC_SUFFIX)]) + if cid is None: + return JSONResponse({"error": "not found"}, status_code=404) + from providers.link_speeds import link_traffic_volumes + # Optional ?min_capacity= restricts to major roads (matches the Volumes + # "major roads only" map filter) so the default view transfers ~10× less. + mc_raw = request.query_params.get("min_capacity") + min_capacity = None + if mc_raw: + try: + min_capacity = float(mc_raw) + except ValueError: + min_capacity = None + rows = await _asyncio.to_thread(link_traffic_volumes, cid, min_capacity) + return JSONResponse(rows) + + # A single transit line's route geometry — a slice of `transit_routes` + # by line_id (tens of KB vs the full ~76 MB asset). The map overlay + # fetches this when a line is selected so it renders immediately instead + # of pulling the whole country's PT geometry into the browser. + mr = _ROUTES_BY_LINE_RE.match(asset_path) + if mr: + from providers.transit_routes import routes_for_line_bytes + payload = await _asyncio.to_thread(routes_for_line_bytes, mr.group(1)) + return _Response(content=payload, media_type="application/geo+json") + # Transit line route geometry (one LineString per route) — served straight # from the `transit_routes` static_asset (GeoJSON BLOB) for the map overlay. if asset_path == "transit/routes/transit_routes.geojson": @@ -269,6 +314,11 @@ async def matsim_asset(dataset_id: int, asset_path: str, request: Request): ms = _STOPS_RE.match(asset_path) if ms: + # A canton's stops just loaded → start building the per-line route + # index in the background so the line draws instantly when the user + # clicks a stop+line moments later (instead of waiting on the parse). + from providers.transit_routes import ensure_warm + ensure_warm() name = ms.group(1) if name == "inter_cantonal": from providers.transit_stops import inter_cantonal_stops diff --git a/webmap-backend/providers/__init__.py b/webmap-backend/providers/__init__.py index 41edd9b..3e98340 100644 --- a/webmap-backend/providers/__init__.py +++ b/webmap-backend/providers/__init__.py @@ -45,10 +45,11 @@ from .node_flows import NodeFlowsProvider from .nodes_geojson import NodesGeoJSONProvider from .zone_flows import ZoneFlowsProvider +from .destination_zones import DestinationZonesProvider from .polygon_trips import PolygonTripsProvider # --- Link speeds --- -from .link_speeds import LinkSpeedsProvider, SpeedDashboardProvider +from .link_speeds import LinkSpeedsProvider, LinkVolumesProvider, SpeedDashboardProvider ALL_PROVIDERS = [ # Demographics @@ -95,9 +96,12 @@ NodesGeoJSONProvider(), # Zone flows (OD canton flows) ZoneFlowsProvider(), + # Destination zones (per-hub-canton outflow/inflow by mode/purpose/time) + DestinationZonesProvider(), # Polygon trips (in/out/within mode summary for a drawn polygon) PolygonTripsProvider(), # Link speeds LinkSpeedsProvider(), + LinkVolumesProvider(), SpeedDashboardProvider(), ] diff --git a/webmap-backend/providers/destination_zones.py b/webmap-backend/providers/destination_zones.py new file mode 100644 index 0000000..1bf5429 --- /dev/null +++ b/webmap-backend/providers/destination_zones.py @@ -0,0 +1,141 @@ +"""Per-canton destination/origin trip flows for the Destination Zones module. + +Backend replacement for the old CDN ``destination_data/{canton}.json`` asset. +Derived from the v2 ``trips`` table (``origin_canton_id`` / ``dest_canton_id`` / +``main_mode`` / ``following_purpose`` / ``departure_time``). + +For a hub canton C it returns one record per (other canton, mode, purpose) with +trip counts bucketed into 15-minute bins — the exact array shape +``DestinationZones.jsx`` consumes:: + + [{ role, origin, destination, mode, purpose, time_bins: {"HH:MM": count} }] + +``role="origin"`` rows are outflow (C → other); ``role="destination"`` rows are +inflow (other → C). Intra-canton trips (other == C) are excluded — the module +visualizes flows *between* cantons (its destination list/arcs already drop the +hub). ``purpose`` is the trip's destination activity (``following_purpose``), +which is what the frontend's purpose filter expects (work/education/shop/leisure). +""" + +from __future__ import annotations + +from .base import DataProvider, Param +from .constants import CANTON_MAP +from .connection import get_source_cursor +from .result_cache import make_cache + +_cget, _cput = make_cache(maxsize=48) + +_NAME_TO_ID = {v.lower(): k for k, v in CANTON_MAP.items()} + + +def _resolve_canton(value: str) -> int | None: + """Resolve a canton name or ID string to a canton ID integer.""" + value = value.strip() + try: + cid = int(value) + if cid in CANTON_MAP: + return cid + except ValueError: + pass + return _NAME_TO_ID.get(value.lower()) + + +def _bin_key(bin15: int) -> str: + """15-minute bin index (floor(seconds/900)) → "HH:MM" label. + + Matches the frontend bucketing (idx = h*4 + minute//15). Trips after + midnight (departure_time ≥ 24 h) yield HH ≥ 24; the frontend's 0–96 slider + naturally drops them, so they're harmless to include. + """ + h = bin15 // 4 + m = (bin15 % 4) * 15 + return f"{h:02d}:{m:02d}" + + +_PARAMS = [ + Param("canton", "Hub canton name or ID", required=True), + Param("source", "Data source", enum=["synthetic", "microcensus"]), +] + + +class DestinationZonesProvider(DataProvider): + """Outflow/inflow trip flows for a hub canton, per (canton, mode, purpose). + + Example: /data/{id}/destination_zones.json?canton=Zurich + """ + + ROUTE = "destination_zones.json" + PARAMS = _PARAMS + + def deliver(self, params: dict): + raw = (params.get("canton") or "").strip() + if not raw: + return {"error": "canton parameter is required"} + cid = _resolve_canton(raw) + if cid is None: + return {"error": f"Unknown canton: {raw}"} + + ckey, hit = _cget(self.ROUTE, params) + if hit is not None: + return hit + + source = (params.get("source") or "synthetic").strip().lower() + if source not in ("synthetic", "microcensus"): + source = "synthetic" + try: + cur = get_source_cursor(source) + except Exception as exc: + return {"error": f"destination_zones data unavailable: {exc}"} + + # One scan, both directions. Each row is tagged with the hub's role and + # the "other" canton; intra-canton trips and rows missing a canton id are + # excluded. 15-min bin index = floor(departure_time / 900). + query = """ + SELECT role, other_id, main_mode, following_purpose, bin15, + COUNT(*)::INTEGER AS cnt + FROM ( + SELECT 'origin' AS role, dest_canton_id AS other_id, + main_mode, following_purpose, + CAST(departure_time // 900 AS INTEGER) AS bin15 + FROM trips + WHERE origin_canton_id = ? AND dest_canton_id IS NOT NULL + AND dest_canton_id <> ? + UNION ALL + SELECT 'destination' AS role, origin_canton_id AS other_id, + main_mode, following_purpose, + CAST(departure_time // 900 AS INTEGER) AS bin15 + FROM trips + WHERE dest_canton_id = ? AND origin_canton_id IS NOT NULL + AND origin_canton_id <> ? + ) + GROUP BY role, other_id, main_mode, following_purpose, bin15 + """ + try: + rows = cur.execute(query, [cid, cid, cid, cid]).fetchall() + except Exception as exc: + return {"error": str(exc)} + + hub = CANTON_MAP.get(cid, str(cid)) + # Collapse to one record per (role, other, mode, purpose) carrying a + # {"HH:MM": count} time_bins dict. + records: dict[tuple, dict] = {} + for role, other_id, mode, purpose, bin15, cnt in rows: + other = CANTON_MAP.get(other_id, str(other_id)) + key = (role, other_id, mode, purpose) + rec = records.get(key) + if rec is None: + rec = { + "role": role, + "origin": hub if role == "origin" else other, + "destination": other if role == "origin" else hub, + "mode": mode, + "purpose": purpose, + "time_bins": {}, + } + records[key] = rec + rec["time_bins"][_bin_key(bin15)] = cnt + + result = list(records.values()) + _cput(ckey, result) + return result diff --git a/webmap-backend/providers/link_speeds.py b/webmap-backend/providers/link_speeds.py index 2b91d09..90b281d 100644 --- a/webmap-backend/providers/link_speeds.py +++ b/webmap-backend/providers/link_speeds.py @@ -127,6 +127,96 @@ def _build_filters(params: dict) -> tuple[str, list]: return where, bind +# ─── Per-link hourly traffic volumes (road "Volumes" module) ──────────────── +# Backend replacement for the old preprocessed CDN asset +# `matsim/{canton}_link_traffic_volumes.json`. Derived from the same +# link_speeds.volume column the speed endpoints use (volume = count of car +# "entered link" events per directed link per 15-min bin). +# +# Result cache keyed by (dataset, canton). Each canton's result is a large +# Python list (Zurich ~116k links → ~100 MB in memory), so the cache is a +# small bounded LRU — repeat loads of the same canton are instant without an +# unbounded leak across all 26 cantons × every dataset a worker touches. +_TRAFFIC_CACHE: "OrderedDict[tuple, list]" = OrderedDict() +_TRAFFIC_CACHE_MAX = 6 + + +def link_traffic_volumes(canton_id: int, min_capacity: float | None = None) -> list: + """Per-link hourly car traffic volumes for a canton. + + Returns ``[{link_id, hourly_avg_volumes}]`` where ``hourly_avg_volumes`` is + a 24-element array indexed by hour of day. Matches the shape the road + "Volumes" module (``useNetworkLayers``) expects: it looks each directed + ``link_id`` up by the segment's ``per_id_keys`` and splits left/right by the + per-link arrow. Links with no traffic are simply absent (→ treated as 0). + + ``min_capacity`` restricts the result to links whose ``network_links.capacity`` + exceeds the threshold — the same ``capacity > 1200`` test the frontend's + "major roads only" map filter uses. The default Volumes view is major-only, + so requesting just those links cuts the payload ~10× (the rest is fetched + lazily when the table opens or the toggle is switched off). Cached per + (dataset, canton, threshold). + """ + ckey = (dataset_key(), canton_id, min_capacity) + cached = _TRAFFIC_CACHE.get(ckey) + if cached is not None: + _TRAFFIC_CACHE.move_to_end(ckey) + return cached + + con = _get_con() + # time_bin is a 15-min bin index (0..95); // 4 → hour (0..23). A flat + # GROUP BY + Python dict fill is the fastest build measured — packing the + # 24-array in SQL (ordered list_agg) or via numpy.unique on the string + # link_ids were both slower. + rows = None + if min_capacity is not None: + try: + rows = con.execute( + """ + SELECT ls.link_id, ls.time_bin // 4 AS hour, SUM(ls.volume)::INTEGER AS volume + FROM link_speeds ls + JOIN network_links nl + ON CAST(nl.link_id AS VARCHAR) = CAST(ls.link_id AS VARCHAR) + WHERE ls.canton_id = ? AND nl.capacity > ? + GROUP BY ls.link_id, ls.time_bin // 4 + """, + [canton_id, min_capacity], + ).fetchall() + except Exception: + # Older dataset without a network_links table → fall back to the full + # (unfiltered) scan; the frontend still filters the map to major roads. + rows = None + if rows is None: + rows = con.execute( + """ + SELECT link_id, time_bin // 4 AS hour, SUM(volume)::INTEGER AS volume + FROM link_speeds + WHERE canton_id = ? + GROUP BY link_id, time_bin // 4 + """, + [canton_id], + ).fetchall() + + by_link: dict[str, list[int]] = {} + for link_id, hour, volume in rows: + arr = by_link.get(link_id) + if arr is None: + arr = [0] * 24 + by_link[link_id] = arr + if 0 <= hour < 24: + arr[hour] = volume + + result = [ + {"link_id": lid, "hourly_avg_volumes": arr} + for lid, arr in by_link.items() + ] + _TRAFFIC_CACHE[ckey] = result + _TRAFFIC_CACHE.move_to_end(ckey) + while len(_TRAFFIC_CACHE) > _TRAFFIC_CACHE_MAX: + _TRAFFIC_CACHE.popitem(last=False) + return result + + _LINK_SPEED_PARAMS = [ Param("road_type", "Road type filter (comma-separated, e.g. motorway,primary)"), Param("canton", "Canton name or ID (comma-separated)"), @@ -192,6 +282,43 @@ def deliver(self, params: dict) -> dict: return result +class LinkVolumesProvider(DataProvider): + """Per-link total daily volume (vehicles) for a canton, summed across time + bins from the link_speeds table. Used by the VolumeFlow module to hide links + that carry no traffic — the old merged-network asset baked a `daily_avg_volume` + attribute per link, but the v2 per-link geometry asset doesn't, so the + frontend computes it from this endpoint instead. Only links with volume > 0 + are returned (absent link → 0 trips → hidden). + + Example: /data/{id}/link_volumes.json?canton=Zurich + """ + + ROUTE = "link_volumes.json" + PARAMS = _LINK_SPEED_PARAMS + + def deliver(self, params: dict) -> dict: + ckey, hit = _cache_get(self.ROUTE, params) + if hit is not None: + return hit + where, bind = _build_filters(params) + + try: + con = _get_con() + rows = con.execute(f""" + SELECT link_id, SUM(volume)::INTEGER AS volume + FROM link_speeds + WHERE {where} + GROUP BY link_id + HAVING SUM(volume) > 0 + """, bind).fetchall() + except Exception as e: + return {"error": str(e)} + + result = {"total_links": len(rows), "links": {r[0]: r[1] for r in rows}} + _cache_put(ckey, result) + return result + + class SpeedDashboardProvider(DataProvider): """Aggregated speed statistics for the dashboard. diff --git a/webmap-backend/providers/municipalities.py b/webmap-backend/providers/municipalities.py index 5b6398c..61f90de 100644 --- a/webmap-backend/providers/municipalities.py +++ b/webmap-backend/providers/municipalities.py @@ -44,6 +44,14 @@ def _load(self) -> dict: data = load_static_asset("synthetic", "municipalities") if data is None: raise FileNotFoundError("municipalities not in static_assets") + # Field mapping for the frontend: CantonMap filters the selected line's + # municipalities by `bfs_nummer`, but the v2 asset stores the BFS number + # under `bfs`. Mirror StopMunicipalityProvider's rename so both transit + # assets expose `bfs_nummer` consistently. + for feat in data.get("features") or []: + props = feat.get("properties") + if props is not None and "bfs_nummer" not in props: + props["bfs_nummer"] = props.get("bfs") _cache[dk] = data return data diff --git a/webmap-backend/providers/network_geometry.py b/webmap-backend/providers/network_geometry.py new file mode 100644 index 0000000..016dd14 --- /dev/null +++ b/webmap-backend/providers/network_geometry.py @@ -0,0 +1,219 @@ +"""Per-canton network geometry, built on demand from the ``network_links`` table. + +The v2 duckdb also ships a precomputed ``merged_segments:{cid}`` static_asset, +but that blob is *thin* — only ``link_id``/``road_type``/``freespeed``. The road +"Volumes" and "MATSim Network" modules need ``modes`` (the car-only mode filter) +and ``capacity`` (line-width + the major-roads toggle); without them the Volumes +car filter matches nothing and the map renders blank. + +``network_links`` already has ``modes``/``capacity``/``length`` plus the LV95 +geometry, so we rebuild the FeatureCollection the frontend expects. Forward and +reverse links that share the same 2D geometry are **merged here** into one +feature per visual segment carrying the index-aligned ``per_id_*`` pipe arrays +(this is what the client's ``mergeSegmentsByGeometry`` used to do on every canton +load — doing it server-side ships each shared geometry once instead of twice, so +the payload roughly halves, and the work is computed once per (dataset, canton) +and shared across users instead of re-run in every browser). The client merge +now no-ops on this output (it sees ``per_id_keys`` already present) and stays +active only for the GitHub-CDN fallback shape. Geometry is reprojected LV95 +(EPSG:2056) → WGS84 and coordinates are rounded to ~0.1 m. Cached per (dataset, +canton). +""" + +from __future__ import annotations + +import json +import threading +from collections import OrderedDict + +from .connection import get_source_cursor +from .paths import dataset_key + +# Serialized GeoJSON bytes per (dataset, canton). Each canton is large +# (Zurich ~178k links → tens of MB), so this is a small bounded LRU. +_CACHE: "OrderedDict[tuple, bytes]" = OrderedDict() +_CACHE_MAX = 6 +_LOCK = threading.Lock() + +_COORD_DECIMALS = 6 # ~0.1 m — plenty for the map; keeps the payload small + + +def _round_coords(geom: dict) -> dict: + """Round LineString/MultiLineString coordinates in place to _COORD_DECIMALS. + + Rounding is deterministic, so a link and its reversed-coordinate twin still + round to identical values — the frontend's geometry-key pairing of + forward+reverse links is preserved. + """ + t = geom.get("type") + c = geom.get("coordinates") + if not c: + return geom + if t == "LineString": + geom["coordinates"] = [[round(x, _COORD_DECIMALS), round(y, _COORD_DECIMALS)] for x, y in c] + elif t == "MultiLineString": + geom["coordinates"] = [ + [[round(x, _COORD_DECIMALS), round(y, _COORD_DECIMALS)] for x, y in line] + for line in c + ] + return geom + + +def _flat_coords(geom: dict): + """Flatten a LineString/MultiLineString geometry to a [[x, y], ...] list.""" + t = geom.get("type") + c = geom.get("coordinates") + if t == "LineString": + return c + if t == "MultiLineString": + return [pt for line in c for pt in line] + return None + + +def _arrow_for_coords(coords) -> str: + """Direction glyph for one link from its own coordinates — the Python twin of + the frontend's ``arrowForCoords``. Westward (start lon > end lon) → ``←``, + otherwise ``→``; falls back to latitude for (near-)vertical links so a + reversed pair still gets opposite glyphs.""" + if not coords or len(coords) < 2: + return "→" + s_lon, s_lat = coords[0][0], coords[0][1] + e_lon, e_lat = coords[-1][0], coords[-1][1] + if s_lon != e_lon: + return "←" if s_lon > e_lon else "→" + return "←" if s_lat > e_lat else "→" + + +def _geometry_key(coords) -> str: + """Direction-independent geometry key — the Python twin of the frontend's + ``geometryKey``: the smaller of the forward and reversed coordinate + sequences, so a link and its reversed-coordinate twin hash to one bucket. + Coords are already rounded deterministically, so the pairing is exact.""" + parts = [f"{x},{y}" for x, y in coords] + fwd = ";".join(parts) + rev = ";".join(reversed(parts)) + return fwd if fwd <= rev else rev + + +def _js_num(v) -> str: + """Stringify a per-link scalar the way the old client merge effectively did + (JSON number → JS ``toString``): integral floats lose the trailing ``.0`` so + the ``per_id_*`` strings and anything reading them stay byte-identical to the + previous client-side output. ``None`` → empty string (dropped by the + frontend's ``parsePipeList``/``pipeMinMax``).""" + if v is None: + return "" + f = float(v) + return str(int(f)) if f == int(f) else repr(f) + + +def merged_segments_geojson(canton_id: int) -> bytes | None: + """Return the canton's network as serialized GeoJSON bytes, or None if the + ``network_links`` table is unavailable (older datasets → caller falls back + to the thin static_asset blob).""" + ckey = (dataset_key(), canton_id) + with _LOCK: + hit = _CACHE.get(ckey) + if hit is not None: + _CACHE.move_to_end(ckey) + return hit + + try: + cur = get_source_cursor("synthetic") + rows = cur.execute( + """ + -- Some PT links carry freespeed = Infinity (and other columns could + -- in principle be NaN/Inf too). json.dumps would emit the literal + -- `Infinity`/`NaN` tokens, which are invalid JSON and make the + -- frontend's res.json() throw ("unexpected character") — the loader + -- then silently falls back to the GitHub CDN. Coerce non-finite + -- values to NULL so the payload is always valid JSON. + SELECT link_id, modes, + CASE WHEN isfinite(capacity) THEN ROUND(capacity, 1) END AS capacity, + -- m/s → km/h, matching the speed module's freespeed_kmh. The + -- Network color ramp (0..150) and the Segment/feature tables + -- all label and expect km/h; network_links stores m/s. + CASE WHEN isfinite(freespeed) THEN ROUND(freespeed * 3.6, 2) END AS freespeed, + CASE WHEN isfinite(length) THEN ROUND(length, 2) END AS length, + CASE WHEN isfinite(permlanes) THEN permlanes END AS permlanes, + road_type, + ST_AsGeoJSON( + ST_Transform(geom, 'EPSG:2056', 'EPSG:4326', always_xy := true) + ) AS gj + FROM network_links + WHERE canton_id = ? + """, + [canton_id], + ).fetchall() + except Exception: + return None # table absent / incompatible dataset → fall back to blob + if not rows: + return None + + # Group directed links by shared 2D geometry (forward + reverse → one + # segment). Insertion order is SQL row order, so the per_id_* arrays come out + # in the same order the old client merge produced. + groups: "OrderedDict[str, dict]" = OrderedDict() + singletons = [] # degenerate geometries that can't merge; appended as-is + for link_id, modes, capacity, freespeed, length, permlanes, road_type, gj in rows: + if not gj: + continue + geom = _round_coords(json.loads(gj)) + coords = _flat_coords(geom) + rep = { + "link_id": link_id, + "modes": modes, + "capacity": capacity, + "freespeed": freespeed, + "length": length, + "permlanes": permlanes, + "road_type": road_type, + } + if not coords or len(coords) < 2: + # Can't form a geometry key — keep as a standalone per-link feature so + # it still parses (carries no per_id_*; won't be clickable-merged). + singletons.append({"type": "Feature", "properties": rep, "geometry": geom}) + continue + key = _geometry_key(coords) + grp = groups.get(key) + if grp is None: + grp = {"geometry": geom, "rep": rep, + "keys": [], "arrows": [], "freespeeds": [], + "capacities": [], "lengths": [], "permlanes": []} + groups[key] = grp + grp["keys"].append(str(link_id)) + grp["arrows"].append(_arrow_for_coords(coords)) + grp["freespeeds"].append(_js_num(freespeed)) + grp["capacities"].append(_js_num(capacity)) + grp["lengths"].append(_js_num(length)) + grp["permlanes"].append(_js_num(permlanes)) + + # Merged segments first so features[0] always carries per_id_keys — the + # frontend's no-op guard only inspects the first feature. + features = [] + for grp in groups.values(): + features.append({ + "type": "Feature", + "properties": { + **grp["rep"], + "per_id_keys": "|".join(grp["keys"]), + "per_id_arrows": "|".join(grp["arrows"]), + "per_id_freespeeds": "|".join(grp["freespeeds"]), + "per_id_capacities": "|".join(grp["capacities"]), + "per_id_lengths": "|".join(grp["lengths"]), + "per_id_permlanes": "|".join(grp["permlanes"]), + }, + "geometry": grp["geometry"], + }) + features.extend(singletons) + + payload = json.dumps( + {"type": "FeatureCollection", "features": features} + ).encode("utf-8") + + with _LOCK: + _CACHE[ckey] = payload + _CACHE.move_to_end(ckey) + while len(_CACHE) > _CACHE_MAX: + _CACHE.popitem(last=False) + return payload diff --git a/webmap-backend/providers/node_flows.py b/webmap-backend/providers/node_flows.py index 736fec2..2ef233d 100644 --- a/webmap-backend/providers/node_flows.py +++ b/webmap-backend/providers/node_flows.py @@ -37,6 +37,61 @@ def _node_links(con, node_id: str): return entering, exiting +# Person-filter params that force the (slow) live spider scan. If none are set +# and the time window covers the full day, the precomputed node_flow_matrix — +# which is a full-day, all-persons aggregate — gives an identical result. +_PERSON_FILTER_KEYS = ( + "sex", "age_min", "age_max", "employed", "has_license", + "car_availability", "home_canton", "polygon_id", "polygon_ids", "income", +) + + +def _is_unfiltered(params: dict) -> bool: + """True when no person filter is set and the time window is the full day.""" + if any(params.get(k) not in (None, "") for k in _PERSON_FILTER_KEYS): + return False + ms = params.get("minute_start") + if ms not in (None, ""): + try: + if int(ms) > 0: + return False + except ValueError: + return False + me = params.get("minute_end") + if me not in (None, ""): + try: + if int(me) < 1440: + return False + except ValueError: + return False + return True + + +def _matrix_result(con, node_id: str, entering: list, exiting: list) -> dict | None: + """Build the node-flow response from the precomputed node_flow_matrix table. + + Returns the full result dict, or None if the table is absent (older + datasets) so the caller can fall back to the live spider query. The matrix + is full-day / all-persons, so only use it when ``_is_unfiltered`` holds. + """ + try: + rows = con.execute( + "SELECT from_link, to_link, n_trips FROM node_flow_matrix WHERE node_id = ?", + [node_id], + ).fetchall() + except Exception: + return None # table not present → fall back to the live query + + matrix: dict[str, dict[str, int]] = {e: {x: 0 for x in exiting} for e in entering} + total = 0 + for from_link, to_link, n in rows: + if from_link in matrix and to_link in matrix[from_link]: + matrix[from_link][to_link] = int(n) + total += int(n) + return {"node_id": node_id, "entering_links": entering, "exiting_links": exiting, + "total_movements": total, "matrix": matrix} + + _NODE_FLOWS_PARAMS = [ Param("node_id", "MATSim node ID (required unless link_id given)"), Param("link_id", "MATSim link ID — derives the to-node automatically"), @@ -85,6 +140,15 @@ def deliver(self, params: dict) -> dict: return {"node_id": node_id, "entering_links": entering, "exiting_links": exiting, "total_movements": 0, "matrix": {}} + # Fast path: a full-day, all-persons request is exactly what the + # precomputed node_flow_matrix holds — a point lookup (~ms) instead of a + # multi-second scan of the 255M-row spider_link_index. Falls back to the + # live query below when the table is missing or filters are active. + if _is_unfiltered(params): + fast = _matrix_result(con, node_id, entering, exiting) + if fast is not None: + return fast + person_clauses, poly_join, poly_bind, hh_join, time_filter, bind_persons, bind_time = \ self._build_filters(params) psubq = self._person_subquery(poly_join, hh_join, person_clauses) diff --git a/webmap-backend/providers/nodes_geojson.py b/webmap-backend/providers/nodes_geojson.py index 780bfe5..7be86b4 100644 --- a/webmap-backend/providers/nodes_geojson.py +++ b/webmap-backend/providers/nodes_geojson.py @@ -3,8 +3,11 @@ Uses the precomputed ``network_nodes.canton_id`` column (from the pipeline) to select the nodes of a canton directly, reprojects them from LV95 (EPSG:2056) to WGS84, and returns a GeoJSON ``FeatureCollection`` — each feature has -``properties.id = node_id``. The assembled collection is cached per -(dataset, canton). No spatial join / cache build at request time. +``properties.id = node_id``. Only real intersections are returned: a node must +have at least 3 distinct car connections (a link and its reversed-coordinate +twin count once), which drops mid-road points and dead-ends. The assembled +collection is cached per (dataset, canton). No spatial join / cache build at +request time. The companion ``node_flows.json`` endpoint computes the flow numbers at a node. Only ``node_id`` + position are available; richer node metadata would need @@ -69,8 +72,32 @@ def deliver(self, params: dict): return JSONResponse(cached) try: cur = get_source_cursor("synthetic") + # Only emit "real" intersections: nodes with >= 3 distinct car + # connections. Reversed-coordinate twins (the forward + reverse + # link of one road) collapse to a single undirected edge via + # LEAST/GREATEST, so a plain mid-road point (2 connections) and + # dead-ends (1) are dropped — that's what removes the clutter. + # Degree is counted network-globally so border nodes still count + # edges from both cantons; the result is cached per (dataset, + # canton) by _fc_cache, so the one scan is amortized. rows = cur.execute( f""" + WITH car AS ( + SELECT from_node, to_node FROM network_links WHERE {_CAR} + ), + edges AS ( + SELECT DISTINCT LEAST(from_node, to_node) AS a, + GREATEST(from_node, to_node) AS b + FROM car + ), + deg AS ( + SELECT a AS node FROM edges + UNION ALL + SELECT b AS node FROM edges + ), + busy AS ( + SELECT node FROM deg GROUP BY node HAVING COUNT(*) >= 3 + ) SELECT node_id, ROUND(ST_X(p), {_COORD_DECIMALS}) AS lng, ROUND(ST_Y(p), {_COORD_DECIMALS}) AS lat @@ -79,11 +106,7 @@ def deliver(self, params: dict): ST_Transform(geom, 'EPSG:2056', 'EPSG:4326', always_xy := true) AS p FROM network_nodes WHERE canton_id = ? - AND node_id IN ( - SELECT from_node FROM network_links WHERE {_CAR} - UNION - SELECT to_node FROM network_links WHERE {_CAR} - ) + AND node_id IN (SELECT node FROM busy) ) """, [cid], diff --git a/webmap-backend/providers/transit_routes.py b/webmap-backend/providers/transit_routes.py new file mode 100644 index 0000000..2739967 --- /dev/null +++ b/webmap-backend/providers/transit_routes.py @@ -0,0 +1,102 @@ +"""Per-line slices of the large `transit_routes` GeoJSON asset. + +Serving the whole `transit_routes` static asset (every route variant of every +line in the country — ~21k features / ~76 MB) just to draw one selected line +forced the browser to download + parse all of it: a multi-second lag before the +line appeared, and a race that sometimes cleared the selection before the +geometry arrived. + +Here we parse the asset once per dataset, group features by `line_id`, +pre-serialise each line's FeatureCollection, then drop the parsed asset — so we +hold roughly the asset's own byte size in memory, not the (much larger) parsed +Python form. Each request is then a dict lookup returning tens of KB. +""" + +from __future__ import annotations + +import json +import threading +from collections import defaultdict + +from .helpers import load_static_asset +from .paths import dataset_key + +_EMPTY = b'{"type":"FeatureCollection","features":[]}' + +# dataset_key -> {line_id: pre-serialised FeatureCollection bytes}. Keyed per +# dataset so a worker serving several datasets never mixes their geometry. +_ds_cache: dict[str, dict[str, bytes]] = {} +_lock = threading.Lock() + + +def _index() -> dict[str, bytes]: + """Build (once per dataset) and return the line_id -> FeatureCollection-bytes + map. Double-checked locking so parallel first-requests collapse onto one + parse of the ~76 MB asset instead of stampeding it.""" + dk = dataset_key() + idx = _ds_cache.get(dk) + if idx is not None: + return idx + with _lock: + idx = _ds_cache.get(dk) + if idx is None: + fc = load_static_asset("synthetic", "transit_routes") or {} + by_line: dict[str, list] = defaultdict(list) + for f in fc.get("features", []): + lid = (f.get("properties") or {}).get("line_id") + if lid is not None: + by_line[lid].append(f) + idx = { + lid: json.dumps( + {"type": "FeatureCollection", "features": feats} + ).encode("utf-8") + for lid, feats in by_line.items() + } + _ds_cache[dk] = idx + return idx + + +def routes_for_line_bytes(line_id: str) -> bytes: + """Pre-serialised GeoJSON FeatureCollection of every route geometry for + *line_id* (empty FeatureCollection if the line has no geometry).""" + return _index().get(line_id, _EMPTY) + + +# dataset_keys for which a background warm has been kicked off, so repeated +# transit requests don't each spawn a thread. +_warming: set[str] = set() +_warm_lock = threading.Lock() + + +def ensure_warm() -> None: + """Build the per-line index in a background daemon thread (once per dataset) + so the first line selection doesn't block on the ~6 s parse of the ~76 MB + routes asset. Safe to call on every transit request: no-ops once the index + is built or a warm is already in flight. + + Lazy by design — only datasets whose transit module is actually opened pay + the memory/parse cost, and the warm is triggered when stops first load (a + canton click), so it's typically ready before the user clicks a line. + Captures the current dataset root and re-applies it inside the thread (the + per-request ContextVar override doesn't propagate to a new thread).""" + dk = dataset_key() + with _warm_lock: + if dk in _ds_cache or dk in _warming: + return + _warming.add(dk) + + def _run(root: str) -> None: + from .paths import set_root_override + set_root_override(root) + try: + _index() + except Exception: + pass + finally: + set_root_override(None) + with _warm_lock: + _warming.discard(dk) + + threading.Thread( + target=_run, args=(dk,), daemon=True, name="warm-transit-routes" + ).start() diff --git a/webmap-frontend/src/components/Map.jsx b/webmap-frontend/src/components/Map.jsx index 0531526..ea7859b 100644 --- a/webmap-frontend/src/components/Map.jsx +++ b/webmap-frontend/src/components/Map.jsx @@ -5,6 +5,7 @@ import useMapbox from './map/useMapbox'; import useCantons from './map/useCantons'; import usePadding from './map/usePadding'; import useNetworkLayers from './map/useNetworkLayers'; +import useNetworkSplitLayers from './map/useNetworkSplitLayers'; import useTransitLayers from './map/useTransitLayers'; import useChoropleth from './map/useChoropleth'; import useDestinationZones from './map/useDestinationZones'; @@ -38,6 +39,7 @@ export default function Map() { setMapLoading, } = useMap(); const { + datasetId, dataURL, setIsFeatureTableOpen, isFeatureTableOpen, @@ -86,7 +88,6 @@ export default function Map() { // for setting loading spinner while loading transit geojson const [isLoading, setIsLoading] = useState(false); - const [isLoadingNodes, setIsLoadingNodes] = useState(false); const [isLoadingSpeeds, setIsLoadingSpeeds] = useState(false); const [isLoadingZoneFlows, setIsLoadingZoneFlows] = useState(false); @@ -140,6 +141,7 @@ export default function Map() { mapRef, loadWithFallback, graphExpandedRef, + datasetId: datasetId, searchCanton: searchCanton, selectedNetworkModes: selectedNetworkModes, showMajorRoadsOnly: showMajorRoadsOnly, @@ -157,9 +159,19 @@ export default function Map() { drawRef: contextDrawRef, }); + // LinkSpeeds-style per-direction split overlay for the Network and Volumes + // modules (offset lines + per-direction click at zoom >= 15; Volumes also gets + // per-direction volume colour + offset labels). Mounted after useNetworkLayers + // so the base network-layer exists when this caps its zoom range. + useNetworkSplitLayers({ + mapRef, + mapReady, + }); + useTransitLayers({ mapRef, loadWithFallback, + datasetId: datasetId, searchCanton: searchCanton, selectedTransitModes: selectedTransitModes, showStopVolumeSymbology: showStopVolumeSymbology, @@ -185,6 +197,7 @@ export default function Map() { useChoropleth({ mapRef, loadWithFallback, + datasetId: datasetId, selectedMode: selectedMode, selectedDataset: selectedDataset, isGraphExpanded: isGraphExpanded, @@ -218,7 +231,6 @@ export default function Map() { useNodeFlowLayers({ mapRef, mapReady, - setIsLoading: setIsLoadingNodes, }); // Link Speeds overlay @@ -279,19 +291,13 @@ export default function Map() { )} - {isLoadingNodes && !isLoading && !isLoadingSpeeds && ( -
-
-
Loading node data...
-
- )} - {isLoadingZoneFlows && !isLoading && !isLoadingSpeeds && !isLoadingNodes && ( + {isLoadingZoneFlows && !isLoading && !isLoadingSpeeds && (
Loading zone flows...
)} - {mapLoading && !isLoading && !isLoadingSpeeds && !isLoadingNodes && !isLoadingZoneFlows && ( + {mapLoading && !isLoading && !isLoadingSpeeds && !isLoadingZoneFlows && (
Updating map...
diff --git a/webmap-frontend/src/components/map/_lib/mapboxFilters.js b/webmap-frontend/src/components/map/_lib/mapboxFilters.js index 86ede5a..b157eee 100644 --- a/webmap-frontend/src/components/map/_lib/mapboxFilters.js +++ b/webmap-frontend/src/components/map/_lib/mapboxFilters.js @@ -5,6 +5,24 @@ * can reuse the same column ↔ property mapping. */ +/** + * Filter for "links a user can click / that should render" in the + * VolumeFlow / NodeFlows / LinkSpeeds modules. + * + * - When `modes` is present (the enriched per-canton `merged_segments` from the + * backend `network_links`, and old merged-visual-segment datasets), clickable + * = car links. We no longer require `daily_avg_volume > 0`: the enriched + * geometry carries no baked per-link volume (the modules fetch speeds/volumes + * from their own endpoints), so a volume gate would hide everything. + * - When `modes` is absent (the thin static_asset blob served as a fallback for + * datasets without `network_links`), the second branch shows every link — + * visualization then keys purely off `link_id`. + */ +export const CLICKABLE_ROAD_FILTER = ['any', + ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0], + ['!', ['has', 'modes']], +]; + /** Operator + value + getter expression → Mapbox comparison filter. */ export const buildComparisonFilter = (operator, value, expression) => { switch (operator) { diff --git a/webmap-frontend/src/components/map/_lib/pipeProps.js b/webmap-frontend/src/components/map/_lib/pipeProps.js index 6f1766d..b58af1a 100644 --- a/webmap-frontend/src/components/map/_lib/pipeProps.js +++ b/webmap-frontend/src/components/map/_lib/pipeProps.js @@ -12,6 +12,113 @@ export const parsePipeList = (str) => (str || '').split('|').filter(Boolean); +/** Flatten a feature's geometry to a [[lng,lat], ...] list, or null. */ +const featureCoords = (f) => { + const g = f?.geometry; + if (g?.type === 'LineString') return g.coordinates; + if (g?.type === 'MultiLineString') return g.coordinates.flat(); + return null; +}; + +/** + * Direction glyph for one link from its own coordinates, matching the old + * `_arrow_for_segment` preprocessing: westward (start lon > end lon) → "←", + * otherwise "→". Lets offset rendering split a merged segment into its two + * opposing directions. + */ +const arrowForCoords = (coords) => { + if (!coords || coords.length < 2) return '→'; + const [sLon, sLat] = coords[0]; + const [eLon, eLat] = coords[coords.length - 1]; + // East/west by longitude, matching the old `_arrow_for_segment`; fall back to + // latitude for (near-)vertical links so a reversed pair still gets opposite + // glyphs (otherwise both land in the same offset bucket and overlap). + if (sLon !== eLon) return sLon > eLon ? '←' : '→'; + return sLat > eLat ? '←' : '→'; +}; + +/** + * Direction-independent geometry key, matching the old `_norm_key`: the smaller + * of the forward and reversed coordinate sequences, so a link and its + * reversed-coordinate twin hash to the same bucket. Reverse links share the + * exact same vertex values (same network export), so a raw join is faithful. + */ +const geometryKey = (coords) => { + const parts = new Array(coords.length); + for (let i = 0; i < coords.length; i++) parts[i] = coords[i][0] + ',' + coords[i][1]; + const fwd = parts.join(';'); + const rev = parts.slice().reverse().join(';'); + return fwd <= rev ? fwd : rev; +}; + +/** + * Merge the new per-link `merged_segments` format (one feature per directed + * MATSim link with a singular `link_id`, served from the duckdb `static_assets` + * BLOB) back into one visual segment per shared 2D geometry — the contract every + * map hook expects. Forward + reverse links collapse into a single clickable + * segment so VolumeFlow can query both directions at once, the link dropdown + * returns, and offset rendering can draw the two directions as parallel lines. + * + * Each merged feature carries (index-aligned, one entry per underlying link): + * per_id_keys — '|'-joined link ids on the segment + * per_id_arrows — '|'-joined direction glyphs, computed on the fly + * per_id_freespeeds — '|'-joined freespeeds (drives freespeed_min/max) + * per_id_capacities — '|'-joined capacities (drives capacity_min/max) + * per_id_lengths — '|'-joined lengths (drives length_min/max) + * per_id_permlanes — '|'-joined lane counts (Segment table, per direction) + * + * Format-agnostic: if features already carry `per_id_keys` (old merged asset or + * CDN fallback) the input is returned unchanged. Returns a (possibly new) + * features array — callers should assign it back. Call before + * decorateLineVolumesFromPerId / decoratePerIdMinMax. + */ +export const mergeSegmentsByGeometry = (features) => { + if (!Array.isArray(features) || features.length === 0) return features; + if (features[0]?.properties?.per_id_keys) return features; // already merged format + + const groups = new Map(); + const singletons = []; + for (const f of features) { + const coords = featureCoords(f); + const linkId = f?.properties?.link_id; + if (!coords || coords.length < 2 || linkId === undefined || linkId === null) { + singletons.push(f); + continue; + } + const key = geometryKey(coords); + let grp = groups.get(key); + if (!grp) { + grp = { feature: f, keys: [], arrows: [], freespeeds: [], capacities: [], lengths: [], permlanes: [] }; + groups.set(key, grp); + } + const p = f.properties; + grp.keys.push(String(linkId)); + grp.arrows.push(arrowForCoords(coords)); + grp.freespeeds.push(p.freespeed ?? ''); + grp.capacities.push(p.capacity ?? ''); + grp.lengths.push(p.length ?? ''); + grp.permlanes.push(p.permlanes ?? ''); + } + + const merged = []; + for (const grp of groups.values()) { + merged.push({ + type: 'Feature', + geometry: grp.feature.geometry, + properties: { + ...grp.feature.properties, + per_id_keys: grp.keys.join('|'), + per_id_arrows: grp.arrows.join('|'), + per_id_freespeeds: grp.freespeeds.join('|'), + per_id_capacities: grp.capacities.join('|'), + per_id_lengths: grp.lengths.join('|'), + per_id_permlanes: grp.permlanes.join('|'), + }, + }); + } + return singletons.length ? merged.concat(singletons) : merged; +}; + /** * Parse pipe-delimited numbers and return the smallest/largest values, or * `{min: null, max: null}` when the string is empty / unparseable. diff --git a/webmap-frontend/src/components/map/useChoropleth.js b/webmap-frontend/src/components/map/useChoropleth.js index 7aa2813..4234d4e 100644 --- a/webmap-frontend/src/components/map/useChoropleth.js +++ b/webmap-frontend/src/components/map/useChoropleth.js @@ -3,18 +3,21 @@ import { useEffect, useState } from 'react'; export default function useChoropleth({ mapRef, loadWithFallback, + datasetId, selectedMode, isGraphExpanded, selectedDataset, aggCol, }) { - + const [modeShareData, setModeShareData] = useState(null); const [maxSharePerMode, setMaxSharePerMode] = useState(null); - + + // datasetId in deps: reload the canton choropleth shares from the new dataset + // when the user switches datasets. useEffect(() => { const path = `${aggCol}_share.json`; - + loadWithFallback(path) .then((data) => { setModeShareData(data); @@ -23,7 +26,7 @@ export default function useChoropleth({ .catch((error) => { console.error("Error loading mode share data:", error); }); - }, [aggCol]); + }, [aggCol, datasetId]); // Set colours for choropleth by mode (matches with plots) const COLOR_MAPS = { diff --git a/webmap-frontend/src/components/map/useFeatureSelectionFocus.js b/webmap-frontend/src/components/map/useFeatureSelectionFocus.js index e27e059..c367456 100644 --- a/webmap-frontend/src/components/map/useFeatureSelectionFocus.js +++ b/webmap-frontend/src/components/map/useFeatureSelectionFocus.js @@ -243,7 +243,8 @@ export default function useFeatureSelectionFocus({ "transit-volumes-label-left", "transit-volumes-label-right", "ant-line"] : isTransitStopsMode ? ["transit-stops-layer", "transit-stops-label", "transit-stops-hitbox", "transit-highlight-layer"] - : ["network-layer", "click-network-layer", "network-highlight", + : ["network-layer", "click-network-layer", "network-highlight", + "network-split-layer", "network-label-left", "network-label-right", "ant-line"]; // --- Build mode filter --- @@ -283,8 +284,30 @@ export default function useFeatureSelectionFocus({ // --- Build table search filter --- let tableFilter = null; - - if (query) { + + // The FeatureTable already computed exactly which rows match the search, so + // mirror that set onto the map instead of re-deriving the search in + // Mapbox-expression syntax: every column, numeric comparison, accent and + // multi-term rule lives in one place (the table) and the map can't drift + // from it. `query.fids` are featureGeoJSON indices and the network source + // uses `generateId` (feature.id === index), so `match` on the feature id + // reproduces the table 1:1 — and `match` compiles its labels to a hash, so + // it stays O(1) per feature no matter how many rows matched. + // + // Gated to modules whose rows are built 1:1 from `featureGeoJSON` (tableId + // === the source's generateId). VolumeFlow is deliberately excluded: its + // table is a per-segment flow breakdown whose `tableId` is a flow-row + // counter in a different id space, so it keeps the legacy column/value path + // (its `directionId` search still filters the network via per_id_keys). + const ROW_MEMBERSHIP_MODULES = ['Network', 'Volumes', 'LinkSpeeds']; + const useRowMembership = + ROW_MEMBERSHIP_MODULES.includes(isGraphExpanded) && Array.isArray(query?.fids); + + if (useRowMembership) { + tableFilter = query.fids.length + ? ["match", ["id"], query.fids, true, false] + : ["==", ["literal", 0], ["literal", 1]]; // searching, zero matches → hide all + } else if (query) { let { column, value } = query; if (value) { @@ -770,6 +793,19 @@ export default function useFeatureSelectionFocus({ } setFilter(map, layerIds, combined); + + // Volumes per-direction split labels (useNetworkSplitLayers) must keep their + // arrow filter, so they can't go through the uniform setFilter above — AND + // the combined car/major/table filter onto each arrow instead (same trick as + // useLinkSpeedsMapFilter), so labels hide in lockstep with their offset lines. + const ARROW_RIGHT = ['==', ['get', 'ls_arrow'], '→']; + const ARROW_LEFT = ['==', ['get', 'ls_arrow'], '←']; + if (map.getLayer('network-split-label-right')) { + map.setFilter('network-split-label-right', combined ? ['all', ARROW_RIGHT, combined] : ARROW_RIGHT); + } + if (map.getLayer('network-split-label-left')) { + map.setFilter('network-split-label-left', combined ? ['all', ARROW_LEFT, combined] : ARROW_LEFT); + } }, [mapRef, mapReady, query, selectedNetworkModes, isGraphExpanded, showMajorRoadsOnly]); } diff --git a/webmap-frontend/src/components/map/useLinkSpeedsLayers.js b/webmap-frontend/src/components/map/useLinkSpeedsLayers.js index 333ecdb..8fa39ca 100644 --- a/webmap-frontend/src/components/map/useLinkSpeedsLayers.js +++ b/webmap-frontend/src/components/map/useLinkSpeedsLayers.js @@ -146,6 +146,8 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) setLinkSpeedsSelected, setSelectedNetworkFeature, setFeatureSelection, + linkSpeedsSelectedLink, + setLinkSpeedsSelectedLink, } = useSelection(); const { featureGeoJSON, @@ -160,6 +162,9 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) const cacheRef = useRef({ key: null, links: null }); // Filtered linksMap available to the selection effect. const filteredLinksRef = useRef({}); + // Last selected segment's id key — used to reset the per-link dropdown to + // "All" whenever a different segment is selected. + const prevSelKeyRef = useRef(null); // Version bump so the selection effect re-runs after data arrives. const [dataVersion, setDataVersion] = useState(0); // Fetched links (stateful so the build effect can depend on it). Keyed by @@ -198,7 +203,7 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) source: SPEEDS_AGG_SOURCE_ID, maxzoom: SPLIT_ZOOM, paint: { - 'line-width': ['interpolate', ['linear'], ['get', 'capacity'], 300, 2, 4000, 9], + 'line-width': ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 2, 4000, 9], 'line-color': ramp, 'line-opacity': 0.9, }, @@ -211,7 +216,7 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) source: SPEEDS_SOURCE_ID, minzoom: SPLIT_ZOOM, paint: { - 'line-width': ['interpolate', ['linear'], ['get', 'capacity'], 300, 2, 4000, 9], + 'line-width': ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 2, 4000, 9], 'line-color': ramp, 'line-opacity': 0.9, 'line-offset': LINE_OFFSET_EXPR, @@ -280,11 +285,14 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) removeOverlay(map); setLinkSpeedsSummary(null); setLinkSpeedsSelected(null); + setLinkSpeedsSelectedLink(null); setLinkSpeedsLinksMap(null); filteredLinksRef.current = {}; + prevSelKeyRef.current = null; setIsLoading?.(false); }, [isGraphExpanded, mapReady, mapRef, removeOverlay, - setLinkSpeedsSummary, setLinkSpeedsSelected, setLinkSpeedsLinksMap, setIsLoading]); + setLinkSpeedsSummary, setLinkSpeedsSelected, setLinkSpeedsSelectedLink, + setLinkSpeedsLinksMap, setIsLoading]); // Per-direction click on the split layer (zoom >= SPLIT_ZOOM). Sets // featureSelection with only that direction's link ids so downstream @@ -345,7 +353,7 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) source: 'network-highlight', paint: { // Narrow highlight — we're only selecting one direction. - 'line-width': ['interpolate', ['linear'], ['get', 'capacity'], 300, 6, 4000, 15], + 'line-width': ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 6, 4000, 15], 'line-color': '#00a2ff', 'line-opacity': 1, 'line-offset': LINE_OFFSET_EXPR, @@ -531,7 +539,7 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) const map = mapRef.current; if (!map.getLayer('network-highlight')) return; - const defaultWidth = ['interpolate', ['linear'], ['get', 'capacity'], 300, 6, 4000, 15]; + const defaultWidth = ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 6, 4000, 15]; // Only widen for merged (non-split) selections — per-direction clicks // highlight a single offset line and should stay narrow. const isSplitSelection = !!featureSelection?.feature?.properties?.ls_arrow; @@ -546,7 +554,7 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) map.setPaintProperty('network-highlight', 'line-width', ['step', ['zoom'], defaultWidth, SPLIT_ZOOM, - ['interpolate', ['linear'], ['get', 'capacity'], 300, 18, 4000, 28], + ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 18, 4000, 28], ]); } else { map.setPaintProperty('network-highlight', 'line-width', defaultWidth); @@ -564,42 +572,91 @@ export default function useLinkSpeedsLayers({ mapRef, mapReady, setIsLoading }) // selection visually disappears when filtered out by road type. const setHighlightVisible = (visible) => setVisibility(map, 'network-highlight', visible); if (!props) { + prevSelKeyRef.current = null; setLinkSpeedsSelected(null); setHighlightVisible(false); return; } + + // A split-layer (per-direction) selection carries ls_arrow; merged + // (low-zoom / base-network) selections don't. The per-link dropdown is + // only offered for the merged case. + const isSplit = !!props.ls_arrow; + + // Reset the per-link dropdown to "All" whenever a different segment is + // selected. Keyed on the selection's id (ls_link_ids for a split click, + // per_id_keys for a merged one). + const selKey = props.ls_link_ids || props.per_id_keys || ''; + if (selKey !== prevSelKeyRef.current) { + prevSelKeyRef.current = selKey; + if (linkSpeedsSelectedLink !== null) { + setLinkSpeedsSelectedLink(null); + return; // re-runs with the dropdown cleared + } + } + // Prefer ls_link_ids (direction-specific) over per_id_keys (full segment) // so split-layer clicks produce direction-specific metrics. const keys = parsePipeList(props.ls_link_ids || props.per_id_keys); const linksMap = filteredLinksRef.current || {}; - let vsum = 0, fsum = 0, volsum = 0; - const matchedIds = []; + const matched = []; // { id, d } for each underlying link with traffic for (const k of keys) { const d = linksMap[k]; - if (d && d.volume && d.avg_speed != null && d.freespeed) { - vsum += d.avg_speed * d.volume; - fsum += d.freespeed * d.volume; - volsum += d.volume; - matchedIds.push(k); - } + if (d && d.volume && d.avg_speed != null && d.freespeed) matched.push({ id: k, d }); } - if (!volsum) { + if (!matched.length) { setLinkSpeedsSelected(null); setHighlightVisible(false); return; } setHighlightVisible(true); - const avg = vsum / volsum; - const free = fsum / volsum; + + const matchedIds = matched.map(m => m.id); + // Single-link view: only for a merged selection when the dropdown points + // at a link that still has traffic. If the chosen link was filtered out + // (e.g. road-type change), fall back to "All". + const single = (!isSplit && linkSpeedsSelectedLink) + ? matched.find(m => m.id === linkSpeedsSelectedLink) + : null; + if (!isSplit && linkSpeedsSelectedLink && !single) { + setLinkSpeedsSelectedLink(null); + return; // re-runs as "All" + } + + let avg, free, dailyVolume, linkId; + if (single) { + avg = single.d.avg_speed; + free = single.d.freespeed; + dailyVolume = Math.round(single.d.volume); + linkId = single.id; + } else { + // Volume-weighted mean across every matched link (the existing + // behaviour — the "mean" shown for a merged double link). + let vsum = 0, fsum = 0, volsum = 0; + for (const { d } of matched) { + vsum += d.avg_speed * d.volume; + fsum += d.freespeed * d.volume; + volsum += d.volume; + } + avg = vsum / volsum; + free = fsum / volsum; + dailyVolume = Number(props.daily_avg_volume) || 0; + linkId = matchedIds.join('|'); + } + setLinkSpeedsSelected({ - linkId: matchedIds.join('|'), + linkId, avgSpeed: Number(avg.toFixed(2)), freespeed: Number(free.toFixed(2)), congestionIndex: free ? Number((avg / free).toFixed(4)) : null, - dailyVolume: Number(props.daily_avg_volume) || 0, + dailyVolume, modes: props.modes || '', + // Underlying link ids with traffic — drives the per-link dropdown. + allKeys: matchedIds, + isSplit, }); - }, [featureSelection, isGraphExpanded, setLinkSpeedsSelected, dataVersion]); + }, [featureSelection, isGraphExpanded, setLinkSpeedsSelected, dataVersion, + linkSpeedsSelectedLink, setLinkSpeedsSelectedLink]); return null; } diff --git a/webmap-frontend/src/components/map/useNetworkLayers.js b/webmap-frontend/src/components/map/useNetworkLayers.js index 37470ca..67ba130 100644 --- a/webmap-frontend/src/components/map/useNetworkLayers.js +++ b/webmap-frontend/src/components/map/useNetworkLayers.js @@ -1,11 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import useAntPath from './useAntPath'; import { safeRemoveLayer, safeRemoveSource, setVisibility, setFilter } from './_lib/mapbox'; -import { parsePipeList, decoratePerIdMinMax, decorateLineVolumesFromPerId } from './_lib/pipeProps'; +import { parsePipeList, decoratePerIdMinMax, decorateLineVolumesFromPerId, mergeSegmentsByGeometry } from './_lib/pipeProps'; +import { CLICKABLE_ROAD_FILTER } from './_lib/mapboxFilters'; export default function useNetworkLayers({ mapRef, searchCanton, + datasetId, loadWithFallback, selectedNetworkModes, showMajorRoadsOnly, @@ -24,7 +26,16 @@ export default function useNetworkLayers({ drawRef }) { const [linkVolumeData, setLinkVolumeData] = useState(null); + // Bumped whenever a fresh network-source is loaded, so the Volumes colour + // recompute re-runs once geometry is ready even if the (now fast major-only) + // volume fetch already finished before it — otherwise colours stay at 0 until + // a time-slider nudge re-triggers the recompute. + const [networkVersion, setNetworkVersion] = useState(0); const originalNetworkGeoJSON = useRef(null); + // Per-canton traffic-volume cache: a cheap major-roads-only variant (the + // default view) and the full variant (fetched lazily when "major roads only" + // is unchecked). Keyed by `${datasetId}:${canton}`. + const volCacheRef = useRef({ key: null, major: null, full: null }); const selectedNetworkModesRef = useRef(selectedNetworkModes); useEffect(() => { @@ -41,34 +52,50 @@ export default function useNetworkLayers({ const keys = parsePipeList(props.per_id_keys); const arrows = parsePipeList(props.per_id_arrows); const daily_avgs = parsePipeList(props.per_id_daily_avgs); - - let left = 0, right = 0; - + + let left = 0, right = 0; // time-windowed directional sums + let leftTotal = 0, rightTotal = 0; // full-day directional sums + // Per-link full-day totals, reconstructed from the backend traffic volumes. + // The v2 `merged_segments.geojson` ships no `per_id_daily_avgs`, so without + // this the selected-segment side panel (keyed off `per_id_daily_avgs`) and + // the table column's map-side numeric filter would be blank/broken. + // Full-day totals are time-window-independent, so they're recomputed + // identically on every slider change. + const fullDayPerId = new Array(keys.length); + keys.forEach((id, index) => { const hourly = linkVolumeData?.[id.toString()]; - let s = 0; + let windowed = 0; // time-windowed → drives left/right + map color + let fullDay = 0; // unfiltered all-day → drives the table's Total column if (hourly && Array.isArray(hourly) && hourly.length === 24) { - // Sum volumes from startHour to endHour using array indexing - for (let h = startHour; h < endHour; h++) { - s += hourly[h] ?? 0; - } + for (let h = startHour; h < endHour; h++) windowed += hourly[h] ?? 0; + for (let h = 0; h < 24; h++) fullDay += hourly[h] ?? 0; } else { - // Fallback to daily average if hourly data not available - s = Number(daily_avgs[index] ?? 0); + // No backend hourly data → fall back to any daily average that shipped + // with the geojson (legacy CDN datasets); 0 for v2. + windowed = Number(daily_avgs[index] ?? 0); + fullDay = Number(daily_avgs[index] ?? 0); } - + fullDayPerId[index] = fullDay; + const arrow = arrows[index]; - if (arrow === '←') left += s; - else if (arrow === '→') right += s; + if (arrow === '←') { left += windowed; leftTotal += fullDay; } + else if (arrow === '→') { right += windowed; rightTotal += fullDay; } }); - + f.properties = { ...f.properties, - daily_avg_volume: left + right, // total + daily_avg_volume: left + right, // time-windowed total left_sum: left, - right_sum: right + right_sum: right, + // Full-day directional totals: the table's "Total Daily Volume" column + // reads these so it stays consistent with the directional "Filtered + // Volume" (left_sum/right_sum) — Total ≥ Filtered, equal at full window. + left_total: leftTotal, + right_total: rightTotal, + per_id_daily_avgs: fullDayPerId.join('|'), }; - + return { left, right, total: left + right }; }; @@ -186,6 +213,15 @@ export default function useNetworkLayers({ return; } + // The webmap backend now serves merged_segments already merged (one feature + // per visual segment carrying per_id_keys/per_id_arrows), so this is a no-op + // on the authoritative path. It still merges the *stripped* per-link format + // (one feature per directed link, singular `link_id`, no per_id_*) that the + // GitHub-CDN fallback / legacy datasets ship, so the downstream hooks + // (VolumeFlow dropdown, LinkSpeeds/NodeFlows offset) work regardless of + // source. No-op whenever features already carry per_id_keys. + networkGeojson.features = mergeSegmentsByGeometry(networkGeojson.features); + originalNetworkGeoJSON.current = networkGeojson; decorateLineVolumesFromPerId(networkGeojson.features); @@ -194,7 +230,10 @@ export default function useNetworkLayers({ setFeatureGeoJSON?.(networkGeojson); map.addSource('network-source', { type: 'geojson', data: networkGeojson, generateId: true }); - + // Signal that the source is ready so the Volumes colour recompute re-runs + // (covers the case where volume data arrived before the geometry). + setNetworkVersion(v => v + 1); + map.addLayer({ id: 'click-network-layer', type: 'line', @@ -243,13 +282,10 @@ export default function useNetworkLayers({ updateNetworkFilter(selectedNetworkModesRef.current); if (graphExpandedRef.current === 'VolumeFlow' || graphExpandedRef.current === 'NodeFlows' || graphExpandedRef.current === 'LinkSpeeds') { - // VolumeFlow/NodeFlows: car roads with >0 volume only (no major roads restriction), no labels - const vfFilter = ['all', - ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']] , 0], - ['>', ['get', 'daily_avg_volume'], 0] - ]; - map.setFilter('click-network-layer', vfFilter); - map.setFilter('network-layer', vfFilter); + // VolumeFlow/NodeFlows: clickable road links (car+volume for rich datasets, + // all links for the stripped per-link merged_segments format), no labels + map.setFilter('click-network-layer', CLICKABLE_ROAD_FILTER); + map.setFilter('network-layer', CLICKABLE_ROAD_FILTER); } else if (graphExpandedRef.current === 'Volumes') { // Volumes: car roads + optional major roads filter const carFilter = ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0]; @@ -273,10 +309,11 @@ export default function useNetworkLayers({ if (!e.features.length) return; // VolumeFlow/NodeFlows have their own click handler on this layer if (graphExpandedRef.current === 'VolumeFlow' || graphExpandedRef.current === 'NodeFlows') return; - // LinkSpeeds: at zoom >= 15 only the split layer's click handler applies. - // Merged (per_id_keys) selection would be wrong when the split visual is - // on-screen, so suppress this base handler entirely past the threshold. - if (graphExpandedRef.current === 'LinkSpeeds' && map.getZoom() >= 15) return; + // LinkSpeeds/Network/Volumes: at zoom >= 15 only the split layer's click + // handler applies. Merged (per_id_keys) selection would be wrong when the + // split visual is on-screen, so suppress this base handler past the threshold. + if ((graphExpandedRef.current === 'LinkSpeeds' || graphExpandedRef.current === 'Network' + || graphExpandedRef.current === 'Volumes') && map.getZoom() >= 15) return; // Skip selection when actively drawing or clicking on draw features if (drawRef?.current) { @@ -341,12 +378,8 @@ export default function useNetworkLayers({ // If "all" modes selected, remove filter (or apply car filter for VolumeFlow) if (!modes || modes.includes('all')) { if (graphExpandedRef.current === 'VolumeFlow' || graphExpandedRef.current === 'NodeFlows' || graphExpandedRef.current === 'LinkSpeeds') { - // VolumeFlow/NodeFlows: car roads with >0 volume only - const vfFilter = ['all', - ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0], - ['>', ['get', 'daily_avg_volume'], 0] - ]; - setFilter(map, ['network-layer', 'click-network-layer'], vfFilter); + // VolumeFlow/NodeFlows: clickable road links (tolerates stripped format) + setFilter(map, ['network-layer', 'click-network-layer'], CLICKABLE_ROAD_FILTER); } else { setFilter(map, ['network-layer', 'click-network-layer', 'network-highlight'], null); } @@ -405,8 +438,9 @@ export default function useNetworkLayers({ const carFilter = ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0]; let fullFilter; if (isGraphExpanded === 'VolumeFlow' || isGraphExpanded === 'NodeFlows' || isGraphExpanded === 'LinkSpeeds') { - // VolumeFlow/NodeFlows: car roads with >0 volume (never major-only) - fullFilter = ['all', carFilter, ['>', ['get', 'daily_avg_volume'], 0]]; + // VolumeFlow/NodeFlows: clickable road links (never major-only; tolerates + // the stripped per-link merged_segments format that lacks modes/volume) + fullFilter = CLICKABLE_ROAD_FILTER; } else if (showMajorRoadsOnly) { fullFilter = ['all', carFilter, ['>', ['get', 'capacity'], 1200]]; } else { @@ -505,12 +539,8 @@ export default function useNetworkLayers({ map.setPaintProperty('network-layer', 'line-opacity', 0.4); map.setPaintProperty('click-network-layer', 'line-width', 10); setLabelVisibility(map, false); - // Apply car-mode + volume>0 filter (all car roads, not just major) - const vfFilter = ['all', - ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0], - ['>', ['get', 'daily_avg_volume'], 0] - ]; - setFilter(map, ['network-layer', 'click-network-layer'], vfFilter); + // Clickable road links (tolerates stripped per-link merged_segments format) + setFilter(map, ['network-layer', 'click-network-layer'], CLICKABLE_ROAD_FILTER); } else { // Network / Volumes: full color ramp const colorRamp = isGraphExpanded === 'Volumes' @@ -529,23 +559,49 @@ export default function useNetworkLayers({ if (map.getLayer('ant-line')) map.removeLayer('ant-line'); }, [isGraphExpanded]); - // --- LOAD per-link hourly volumes (unchanged path format you use) ---------- + // --- LOAD per-link hourly volumes ------------------------------------------ + // The default Volumes view is "major roads only", and the map filters to the + // same capacity > 1200 set, so by default we only fetch major-road volumes + // (?min_capacity=1200 → ~10× smaller payload, much faster first colour). The + // full set is fetched lazily only when "major roads only" is unchecked + // (minor roads then become visible and need their volumes). Both variants + // are cached per canton so toggling back is instant. useEffect(() => { - const loadAllLinkVolumes = async () => { - if (!searchCanton || graphExpandedRef.current !== 'Volumes') return; + if (!searchCanton || graphExpandedRef.current !== 'Volumes') return; + + const cacheKey = `${datasetId}:${searchCanton}`; + if (volCacheRef.current.key !== cacheKey) { + volCacheRef.current = { key: cacheKey, major: null, full: null }; + } + const cache = volCacheRef.current; + const needFull = !showMajorRoadsOnly; + + // Serve the best already-cached variant (full is a superset of major). + if (cache.full) { setLinkVolumeData(cache.full); return; } + if (!needFull && cache.major) { setLinkVolumeData(cache.major); return; } + // Full needed but only major cached → show major now while full loads. + if (needFull && cache.major) setLinkVolumeData(cache.major); + + let cancelled = false; + (async () => { + const path = needFull + ? `matsim/${searchCanton}_link_traffic_volumes.json` + : `matsim/${searchCanton}_link_traffic_volumes.json?min_capacity=1200`; try { - const path = `matsim/${searchCanton}_link_traffic_volumes.json`; const raw = await loadWithFallback(path); + if (cancelled || volCacheRef.current.key !== cacheKey) return; const volumeMap = Object.fromEntries( raw.map(e => [e.link_id.toString(), e.hourly_avg_volumes]) ); - setLinkVolumeData(volumeMap); + if (needFull) volCacheRef.current.full = volumeMap; + else volCacheRef.current.major = volumeMap; + setLinkVolumeData(volCacheRef.current.full || volumeMap); } catch (err) { console.warn('Failed to load all link volumes', err); } - }; - loadAllLinkVolumes(); - }, [searchCanton, isGraphExpanded]); + })(); + return () => { cancelled = true; }; + }, [searchCanton, isGraphExpanded, datasetId, showMajorRoadsOnly]); // --- APPLY timeRange to both line data and labels -------------------------- useEffect(() => { @@ -584,8 +640,8 @@ export default function useNetworkLayers({ map.on('sourcedata', onSourceData); } - }, [timeRange, linkVolumeData, isGraphExpanded, showMajorRoadsOnly]); - + }, [timeRange, linkVolumeData, isGraphExpanded, showMajorRoadsOnly, networkVersion]); + // --- Canton change / cleanup ---------------------------------------------- useEffect(() => { const map = mapRef.current; @@ -598,7 +654,10 @@ export default function useNetworkLayers({ 'network-label-left','network-label-right']); safeRemoveSource(map, ['network-source','network-highlight','ant-path']); } - }, [searchCanton]); + // datasetId: on a dataset switch, reload the active network module's + // geometry for the current canton from the new dataset (loadNetworkForCanton + // always fetches fresh, so the stale cache is replaced). + }, [searchCanton, datasetId]); useEffect(() => { const map = mapRef.current; diff --git a/webmap-frontend/src/components/map/useNetworkSplitLayers.js b/webmap-frontend/src/components/map/useNetworkSplitLayers.js new file mode 100644 index 0000000..ffaa205 --- /dev/null +++ b/webmap-frontend/src/components/map/useNetworkSplitLayers.js @@ -0,0 +1,361 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { nearestPointOnLine, lineString, point } from '@turf/turf'; +import { useModule } from '../../context/ModuleContext'; +import { useSelection } from '../../context/SelectionContext'; +import { useData } from '../../context/DataContext'; +import { useMap } from '../../context/MapContext'; +import { safeRemoveLayer, safeRemoveSource, setVisibility } from './_lib/mapbox'; +import { parsePipeList } from './_lib/pipeProps'; + +// LinkSpeeds-style "double link" rendering for the MATSim Network module: below +// SPLIT_ZOOM the shared base `network-layer` draws one merged line per segment +// (its existing colour/filter/selection machinery is untouched); at/above +// SPLIT_ZOOM that base layer is capped out and this overlay draws one offset +// line per direction, so a forward+reverse pair becomes two parallel lines that +// can be clicked individually. The merged-segment per_id_* arrays already carry +// everything we need, so no extra fetch — we just regroup them by direction. +// +// Handles 'Network' (freespeed colour, offsets, no labels) and 'Volumes' +// (per-direction volume colour + offset direction labels) through the same +// overlay; the two differ only in the split layer's colour expression and +// whether direction labels are drawn. + +const SPLIT_ZOOM = 15; +const SPLIT_SOURCE_ID = 'network-split-source'; +const SPLIT_LAYER_ID = 'network-split-layer'; +const VOL_LABEL_RIGHT = 'network-split-label-right'; +const VOL_LABEL_LEFT = 'network-split-label-left'; +const BASE_LAYER_ID = 'network-layer'; +// Base directional labels (useNetworkLayers, centred on the merged line) — hidden +// while Volumes split labels are active so the two don't double up. +const BASE_LABEL_IDS = ['network-label-left', 'network-label-right']; +const HIGHLIGHT_ID = 'network-highlight'; + +const RIGHT = '→'; // → +const LEFT = '←'; // ← + +// Network freespeed ramp — identical to the base network-layer (useNetworkLayers), +// so the split lines look like the network they replace at high zoom. +const FREESPEED_RAMP = ['interpolate', ['linear'], ['get', 'freespeed'], + 0, '#ffffb2', 25, '#fed976', 50, '#feb24c', 75, '#fd8d3c', + 100, '#fc4e2a', 125, '#e31a1c', 150, '#b10026']; + +// Volumes ramp on the per-direction windowed volume (`ns_volume`) — matches the +// base Volumes colour ramp so a segment's split lines keep the same scale. +const VOLUME_RAMP = ['interpolate', ['linear'], ['get', 'ns_volume'], + 0, '#ffffcc', 50, '#c2e699', 100, '#78c679', 250, '#31a354', 500, '#006837']; + +const WIDTH_EXPR = ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 1, 4000, 8]; + +// Parallel-direction offset, same convention as useLinkSpeedsLayers: line-offset +// is perpendicular to drawing direction, so normalise by bearing (`angle`) to +// keep → visually on the right when the map is north-up. +const isWestish = ['any', ['>', ['get', 'angle'], 90], ['<=', ['get', 'angle'], -90]]; +// Offset magnitude scales with capacity like the line width (WIDTH_EXPR: ~1..8px), +// so the two parallel direction lines keep a roughly constant gap instead of +// looking too far apart on thin links — but wide enough that each direction is +// separable/clickable on its own (a bit more than half the line width). +const OFFSET_MAG = ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 2.5, 4000, 7]; +const OFFSET_NEG = ['*', -1, OFFSET_MAG]; +const LINE_OFFSET_EXPR = ['case', + ['!', ['get', 'ls_needs_offset']], 0, + ['==', ['get', 'ls_arrow'], RIGHT], + ['case', isWestish, OFFSET_NEG, OFFSET_MAG], + ['case', isWestish, OFFSET_MAG, OFFSET_NEG], +]; + +// Direction-label text offset — same wide/normal scheme as useLinkSpeedsLayers so +// the number rides its own offset line (wider gap when both directions present). +const LABEL_OFFSET_NORMAL = 1; +const LABEL_OFFSET_WIDE = 1.6; +const LABEL_OFFSET_RIGHT = [0, ['case', + ['get', 'ls_needs_offset'], ['case', isWestish, -LABEL_OFFSET_WIDE, LABEL_OFFSET_WIDE], + ['case', isWestish, -LABEL_OFFSET_NORMAL, LABEL_OFFSET_NORMAL], +]]; +const LABEL_OFFSET_LEFT = [0, ['case', + ['get', 'ls_needs_offset'], ['case', isWestish, LABEL_OFFSET_WIDE, -LABEL_OFFSET_WIDE], + ['case', isWestish, LABEL_OFFSET_NORMAL, -LABEL_OFFSET_NORMAL], +]]; + +// Regroup each merged segment's links by direction into per-direction features. +// Each split feature spreads the parent props (so modes/capacity/per_id_* and +// the freespeed colour all carry over) and sets `id = parent index` so every +// filter useFeatureSelectionFocus applies to the base layer applies here too. +function buildSplitFeatures(features) { + const out = []; + for (let idx = 0; idx < features.length; idx++) { + const f = features[idx]; + const props = f.properties || {}; + const keys = parsePipeList(props.per_id_keys); + if (!keys.length) continue; + const arrows = parsePipeList(props.per_id_arrows); + const right = []; + const left = []; + for (let i = 0; i < keys.length; i++) { + (arrows[i] === LEFT ? left : right).push(keys[i]); + } + const needsOffset = right.length > 0 && left.length > 0; + // Per-direction windowed volume for the Volumes colour ramp + labels. + // right_sum/left_sum are kept time-window-current by useNetworkLayers + // (→ = right_sum, ← = left_sum); harmless 0s in Network mode. + const rightVol = Number(props.right_sum) || 0; + const leftVol = Number(props.left_sum) || 0; + const mk = (ids, arrow, vol) => ({ + type: 'Feature', + id: idx, + geometry: f.geometry, + properties: { + ...props, + ls_arrow: arrow, + ls_needs_offset: needsOffset, + ls_link_ids: ids.join('|'), + ns_volume: vol, + }, + }); + if (right.length) out.push(mk(right, RIGHT, rightVol)); + if (left.length) out.push(mk(left, LEFT, leftVol)); + } + return out; +} + +export default function useNetworkSplitLayers({ mapRef, mapReady }) { + const { isGraphExpanded } = useModule(); + const { featureGeoJSON } = useData(); + const { labelSize } = useMap(); + const { + featureSelection, + setFeatureSelection, + setSelectedNetworkFeature, + setNetworkSelectedLink, + triggerVisualize, + } = useSelection(); + + // The modules this overlay treats as "split-capable". + const isVolumes = isGraphExpanded === 'Volumes'; + const active = isGraphExpanded === 'Network' || isVolumes; + + // Track the last selected segment so the per-link dropdown resets to "All" + // whenever a different segment is selected. + const prevSelKeyRef = useRef(null); + // The module the split layer/labels were last built for, so we recreate them + // (different colour ramp; labels only in Volumes) on a Network↔Volumes switch. + const builtModuleRef = useRef(null); + + const teardown = useCallback((map) => { + safeRemoveLayer(map, [SPLIT_LAYER_ID, VOL_LABEL_RIGHT, VOL_LABEL_LEFT]); + safeRemoveSource(map, [SPLIT_SOURCE_ID]); + builtModuleRef.current = null; + // Restore the base layer to all-zoom rendering + base directional labels. + if (map.getLayer(BASE_LAYER_ID)) map.setLayerZoomRange(BASE_LAYER_ID, 0, 24); + setVisibility(map, BASE_LABEL_IDS, true); + }, []); + + // Build the Volumes direction-label layers (text rides each offset line). + const addVolumeLabels = useCallback((map, size) => { + const common = { + 'symbol-placement': 'line-center', + 'symbol-spacing': 9999999, + 'text-keep-upright': true, + 'text-size': size, + 'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'], + 'text-allow-overlap': true, + }; + const paint = { 'text-color': '#111', 'text-halo-color': '#fff', 'text-halo-width': 1.5 }; + if (!map.getLayer(VOL_LABEL_RIGHT)) { + map.addLayer({ + id: VOL_LABEL_RIGHT, type: 'symbol', source: SPLIT_SOURCE_ID, minzoom: SPLIT_ZOOM, + filter: ['==', ['get', 'ls_arrow'], RIGHT], + layout: { + ...common, + // Blank when the direction carries no volume (matches base labels). + 'text-field': ['case', ['==', ['round', ['get', 'ns_volume']], 0], '', + ['concat', ['to-string', ['round', ['get', 'ns_volume']]], ' →']], + 'text-offset': LABEL_OFFSET_RIGHT, + }, + paint, + }); + } + if (!map.getLayer(VOL_LABEL_LEFT)) { + map.addLayer({ + id: VOL_LABEL_LEFT, type: 'symbol', source: SPLIT_SOURCE_ID, minzoom: SPLIT_ZOOM, + filter: ['==', ['get', 'ls_arrow'], LEFT], + layout: { + ...common, + 'text-field': ['case', ['==', ['round', ['get', 'ns_volume']], 0], '', + ['concat', '← ', ['to-string', ['round', ['get', 'ns_volume']]]]], + 'text-offset': LABEL_OFFSET_LEFT, + }, + paint, + }); + } + }, []); + + // --- Build effect: split source/layer (+ Volumes labels) + base-layer handoff --- + useEffect(() => { + if (!mapReady || !mapRef.current) return; + const map = mapRef.current; + + if (!active) { + teardown(map); + setNetworkSelectedLink(null); + prevSelKeyRef.current = null; + return; + } + + if (!featureGeoJSON?.features) { + // Canton cleared / not loaded yet — drop the overlay so it can't show + // stale geometry, but keep the module active. + safeRemoveLayer(map, [SPLIT_LAYER_ID, VOL_LABEL_RIGHT, VOL_LABEL_LEFT]); + safeRemoveSource(map, [SPLIT_SOURCE_ID]); + builtModuleRef.current = null; + return; + } + + const fc = { type: 'FeatureCollection', features: buildSplitFeatures(featureGeoJSON.features) }; + const src = map.getSource(SPLIT_SOURCE_ID); + if (src) { + src.setData(fc); + } else { + map.addSource(SPLIT_SOURCE_ID, { type: 'geojson', data: fc }); + } + + // Network↔Volumes switch → different colour ramp (and labels) → recreate. + if (builtModuleRef.current !== isGraphExpanded) { + safeRemoveLayer(map, [SPLIT_LAYER_ID, VOL_LABEL_RIGHT, VOL_LABEL_LEFT]); + builtModuleRef.current = isGraphExpanded; + } + + if (!map.getLayer(SPLIT_LAYER_ID)) { + map.addLayer({ + id: SPLIT_LAYER_ID, + type: 'line', + source: SPLIT_SOURCE_ID, + minzoom: SPLIT_ZOOM, + paint: { + 'line-width': WIDTH_EXPR, + 'line-color': isVolumes ? VOLUME_RAMP : FREESPEED_RAMP, + 'line-opacity': 1, + 'line-offset': LINE_OFFSET_EXPR, + }, + }); + } + + if (isVolumes) { + addVolumeLabels(map, Number(labelSize) || 11); + } + // Hide the base centred labels while the overlay is active: Volumes draws + // its own offset split labels instead, and Network has no labels anyway. + setVisibility(map, BASE_LABEL_IDS, false); + + // Hand off agg ↔ split: base layer only below SPLIT_ZOOM, overlay only + // at/above it (its minzoom). Prevents the merged line drawing under the + // offset pair. + if (map.getLayer(BASE_LAYER_ID)) map.setLayerZoomRange(BASE_LAYER_ID, 0, SPLIT_ZOOM); + }, [mapReady, mapRef, active, isVolumes, isGraphExpanded, featureGeoJSON, labelSize, + teardown, addVolumeLabels, setNetworkSelectedLink]); + + // Volumes label size slider → update split label text-size in place. + useEffect(() => { + if (!mapReady || !mapRef.current) return; + const map = mapRef.current; + const size = Number(labelSize) || 11; + [VOL_LABEL_RIGHT, VOL_LABEL_LEFT].forEach((id) => { + if (map.getLayer(id)) map.setLayoutProperty(id, 'text-size', size); + }); + }, [labelSize, mapReady, mapRef]); + + // --- Split-layer click: pick the clicked direction, select just its link(s) --- + useEffect(() => { + if (!mapReady || !mapRef.current || !active) return; + const map = mapRef.current; + + // Both split features share one geometry, so queryRenderedFeatures returns + // both — disambiguate by comparing the click's side to each feature's + // paint-offset sign (same approach as useLinkSpeedsLayers). + const pickByClickSide = (hits, clickLngLat) => { + if (hits.length === 1) return hits[0]; + const ref = hits[0]; + if (!ref.properties.ls_needs_offset) return ref; + const geom = ref.geometry; + const coords = geom.type === 'LineString' ? geom.coordinates : geom.coordinates[0]; + if (!coords || coords.length < 2) return ref; + const snap = nearestPointOnLine(lineString(coords), point([clickLngLat.lng, clickLngLat.lat])); + const i = Math.min(snap.properties.index ?? 0, coords.length - 2); + const a = coords[i], b = coords[i + 1]; + const vx = b[0] - a[0], vy = b[1] - a[1]; + const wx = clickLngLat.lng - a[0], wy = clickLngLat.lat - a[1]; + const clickIsRight = (vx * wy - vy * wx) < 0; + const offsetSign = (arrow, angle) => { + const isWest = angle > 90 || angle <= -90; + if (arrow === RIGHT) return isWest ? -1 : 1; + return isWest ? 1 : -1; + }; + const want = clickIsRight ? 1 : -1; + return hits.find(h => offsetSign(h.properties.ls_arrow, h.properties.angle) === want) || ref; + }; + + const onClick = (e) => { + if (!e.features?.length) return; + const clicked = pickByClickSide(e.features, e.lngLat); + + // Offset highlight for the single clicked direction (created before + // setFeatureSelection so useFeatureSelectionFocus keeps this paint and + // only refreshes the source). + safeRemoveLayer(map, HIGHLIGHT_ID); + safeRemoveSource(map, HIGHLIGHT_ID); + map.addSource(HIGHLIGHT_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [clicked] } }); + map.addLayer({ + id: HIGHLIGHT_ID, + type: 'line', + source: HIGHLIGHT_ID, + paint: { + 'line-width': ['interpolate', ['linear'], ['coalesce', ['get', 'capacity'], 1000], 300, 6, 4000, 15], + 'line-color': '#00a2ff', + 'line-opacity': 1, + 'line-offset': LINE_OFFSET_EXPR, + }, + }, map.getLayer(SPLIT_LAYER_ID) ? SPLIT_LAYER_ID : BASE_LAYER_ID); + + setSelectedNetworkFeature([clicked.properties]); + const g = clicked.geometry; + const coords = g?.type === 'LineString' ? g.coordinates + : g?.type === 'MultiLineString' ? g.coordinates.flat() + : null; + if (coords && setFeatureSelection) { + setFeatureSelection({ + id: clicked.properties.ls_link_ids, + feature: clicked, + coords, + fromMap: true, + }); + } + }; + const onEnter = () => { map.getCanvas().style.cursor = 'pointer'; }; + const onLeave = () => { map.getCanvas().style.cursor = ''; }; + + map.on('click', SPLIT_LAYER_ID, onClick); + map.on('mouseenter', SPLIT_LAYER_ID, onEnter); + map.on('mouseleave', SPLIT_LAYER_ID, onLeave); + return () => { + map.off('click', SPLIT_LAYER_ID, onClick); + map.off('mouseenter', SPLIT_LAYER_ID, onEnter); + map.off('mouseleave', SPLIT_LAYER_ID, onLeave); + }; + }, [mapReady, mapRef, active, setFeatureSelection, setSelectedNetworkFeature]); + + // --- Reset the per-link dropdown to "All" whenever the selected segment + // changes (keyed on ls_link_ids for a split click, per_id_keys for merged), + // and clear any ant-line left over from a previous link's "Visualize". --- + useEffect(() => { + if (!active) return; + const props = featureSelection?.feature?.properties; + const selKey = props ? (props.ls_link_ids || props.per_id_keys || '') : null; + if (selKey !== prevSelKeyRef.current) { + prevSelKeyRef.current = selKey; + setNetworkSelectedLink(null); + triggerVisualize(null); + } + }, [active, featureSelection, setNetworkSelectedLink, triggerVisualize]); + + return null; +} diff --git a/webmap-frontend/src/components/map/useNodeFlowLayers.js b/webmap-frontend/src/components/map/useNodeFlowLayers.js index 9a9e514..44b5569 100644 --- a/webmap-frontend/src/components/map/useNodeFlowLayers.js +++ b/webmap-frontend/src/components/map/useNodeFlowLayers.js @@ -7,6 +7,7 @@ import { useDebounced } from '../../hooks/useDebounced'; import { handle401 } from '../../utils/auth'; import { safeRemoveLayer, safeRemoveSource, setFilter } from './_lib/mapbox'; import { parsePipeList } from './_lib/pipeProps'; +import { CLICKABLE_ROAD_FILTER } from './_lib/mapboxFilters'; // Layer/source IDs const NODES_SOURCE = 'node-flows-nodes'; @@ -119,7 +120,7 @@ function fetchNodesGeoJSON(datasetId, canton) { return p; } -export default function useNodeFlowLayers({ mapRef, mapReady, setIsLoading }) { +export default function useNodeFlowLayers({ mapRef, mapReady }) { const { isGraphExpanded } = useModule(); const { clickedCanton, hoveredMatrixCell, setHoveredMatrixCell } = useSelection(); const { featureGeoJSON, setNodeFlowsData, datasetId } = useData(); @@ -628,11 +629,7 @@ export default function useNodeFlowLayers({ mapRef, mapReady, setIsLoading }) { if (map.getLayer('network-layer')) map.setPaintProperty('network-layer', 'line-opacity', 0.4); - const vfFilter = ['all', - ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0], - ['>', ['get', 'daily_avg_volume'], 0], - ]; - setFilter(map, ['network-layer', 'click-network-layer'], vfFilter); + setFilter(map, ['network-layer', 'click-network-layer'], CLICKABLE_ROAD_FILTER); if (!clickedCanton) return; @@ -659,13 +656,9 @@ export default function useNodeFlowLayers({ mapRef, mapReady, setIsLoading }) { console.log('[NodeFlows] Clicked node:', nodeId, coords); lastNodeRef.current = { nodeId, coords }; - setIsLoading?.(true); - let data; - try { - data = await fetchNodeFlows(nodeId); - } finally { - setIsLoading?.(false); - } + // No loading overlay here — node flows resolve in ~tens of ms + // from the precomputed node_flow_matrix fast path. + const data = await fetchNodeFlows(nodeId); console.log('[NodeFlows] Received data:', data); if (!cancelled && data) { renderOverlay(map, data, coords); diff --git a/webmap-frontend/src/components/map/useTransitLayers.js b/webmap-frontend/src/components/map/useTransitLayers.js index d655331..fa410a5 100644 --- a/webmap-frontend/src/components/map/useTransitLayers.js +++ b/webmap-frontend/src/components/map/useTransitLayers.js @@ -9,6 +9,7 @@ import useTransitSymbologyLayer from "./useTransitSymbologyLayer"; export default function useTransitLayers({ mapRef, loadWithFallback, + datasetId, searchCanton, selectedTransitModes, showStopVolumeSymbology, @@ -35,6 +36,7 @@ export default function useTransitLayers({ useTransitStops({ mapRef, searchCanton, + datasetId, isGraphExpanded, loadWithFallback, showStopVolumeSymbology, @@ -72,6 +74,7 @@ export default function useTransitLayers({ mapRef, isGraphExpanded, searchCanton, + datasetId, timeRange, loadWithFallback, selectedTransitModes, @@ -86,6 +89,7 @@ export default function useTransitLayers({ useTransitSymbologyLayer({ mapRef, searchCanton, + datasetId, isGraphExpanded, highlightedLineId, loadWithFallback, diff --git a/webmap-frontend/src/components/map/useTransitLines.js b/webmap-frontend/src/components/map/useTransitLines.js index 21e2169..913b4c1 100644 --- a/webmap-frontend/src/components/map/useTransitLines.js +++ b/webmap-frontend/src/components/map/useTransitLines.js @@ -54,22 +54,32 @@ export default function useTransitLines( return; } - // If we have a line ID but no route IDs yet, skip rendering (waiting for route IDs to be set) - if (!highlightedRouteIds || highlightedRouteIds.length === 0) { - return; - } + // No route-id gating: selection is keyed purely off line_id. // load current selected transit line and create layer on map const loadRoutes = async () => { - const geojson = await loadWithFallback("matsim/transit/routes/transit_routes.geojson"); - const routeIdsToShow = hoveredRouteId ? [hoveredRouteId] : highlightedRouteIds; - - const matched = geojson.features.filter( - (f) => - f.properties.line_id === highlightedLineId && - routeIdsToShow.includes(f.properties.route_id) + // Fetch only the selected line's geometry (tens of KB) instead of + // the whole ~76 MB transit_routes asset — the backend slices it by + // line_id. Fall back to the full asset (uploaded files / CDN) if + // the per-line endpoint isn't available, then filter client-side. + const encodedLine = encodeURIComponent(highlightedLineId); + let features; + try { + const fc = await loadWithFallback(`matsim/transit/routes/by_line/${encodedLine}.geojson`); + features = fc?.features || []; + } catch { + const fc = await loadWithFallback("matsim/transit/routes/transit_routes.geojson"); + features = fc?.features || []; + } + + // Draw every route geometry belonging to the selected line. The + // duckdb boarding data has no per-stop route_id, so selection is + // keyed purely off line_id — we show the whole line (all its + // routes), which is what visually connects the stops. + const matched = features.filter( + (f) => f.properties.line_id === highlightedLineId ); - + if (matched.length === 0) return; @@ -114,10 +124,6 @@ export default function useTransitLines( const interCantonalStopsGeo = await loadWithFallback("matsim/transit/stops_by_canton/inter_cantonal_stops.geojson"); if (interCantonalStopsGeo && searchCanton) { - const relevantRouteIds = hoveredRouteId - ? [hoveredRouteId] - : highlightedRouteIds; - // calculate stops that are outside the current canton but on the transit line const outOfCantonStops = interCantonalStopsGeo.features.filter(f => { const stopCanton = f.properties.assigned_canton; @@ -129,12 +135,10 @@ export default function useTransitLines( } catch (e) { linesList = f.properties.lines || []; } - - const servesRelevantRoute = linesList.some(l => - l.line_id === highlightedLineId && relevantRouteIds.includes(l.route_id) - ); - - return servesRelevantRoute && stopCanton !== searchCanton; + + const servesLine = linesList.some(l => l.line_id === highlightedLineId); + + return servesLine && stopCanton !== searchCanton; }); @@ -233,14 +237,9 @@ export default function useTransitLines( setClickedCanton(assigned_canton); // delay re-selecting until the canton is loaded - setTimeout(() => { - const updatedRouteIds = JSON.parse(lines) - .filter(l => l.line_id === highlightedLineId) - .map(l => l.route_id); - + setTimeout(() => { setHighlightedLineId(highlightedLineId); - setHighlightedRouteIds(updatedRouteIds); - + setSelectedTransitStop({ name, stop_id, @@ -285,7 +284,7 @@ export default function useTransitLines( }; loadRoutes(); - }, [highlightedRouteIds, showStopVolumeSymbology, highlightedLineId, hoveredRouteId, isGraphExpanded]); + }, [showStopVolumeSymbology, highlightedLineId, isGraphExpanded]); // Clear highlighted line if the current mode filter excludes its mode useEffect(() => { @@ -296,7 +295,7 @@ export default function useTransitLines( const ensure = async () => { try { - const routes = await loadWithFallback('matsim/transit/routes/transit_routes.geojson'); + const routes = await loadWithFallback(`matsim/transit/routes/by_line/${encodeURIComponent(highlightedLineId)}.geojson`); const f = routes?.features?.find(r => r?.properties?.line_id === highlightedLineId); const lineMode = f?.properties?.mode && String(f.properties.mode); if (lineMode && !selectedTransitModes.includes(lineMode)) { diff --git a/webmap-frontend/src/components/map/useTransitStops.js b/webmap-frontend/src/components/map/useTransitStops.js index 6513848..5f8e7c4 100644 --- a/webmap-frontend/src/components/map/useTransitStops.js +++ b/webmap-frontend/src/components/map/useTransitStops.js @@ -4,6 +4,7 @@ import { safeRemoveLayer, safeRemoveSource, setFilter } from './_lib/mapbox'; export default function useTransitStops({ mapRef, searchCanton, + datasetId, isGraphExpanded, loadWithFallback, showStopVolumeSymbology, @@ -451,7 +452,7 @@ export default function useTransitStops({ .catch(err => { console.error("Error loading transit data:", err); }); -}, [isGraphExpanded, searchCanton, showStopVolumeSymbology, selectedTransitModes, timeRange, selectedDirection]); +}, [isGraphExpanded, searchCanton, datasetId, showStopVolumeSymbology, selectedTransitModes, timeRange, selectedDirection]); useEffect(() => { diff --git a/webmap-frontend/src/components/map/useTransitSymbologyLayer.js b/webmap-frontend/src/components/map/useTransitSymbologyLayer.js index 5d643a1..8263050 100644 --- a/webmap-frontend/src/components/map/useTransitSymbologyLayer.js +++ b/webmap-frontend/src/components/map/useTransitSymbologyLayer.js @@ -4,6 +4,7 @@ import { safeRemoveLayer, safeRemoveSource } from './_lib/mapbox'; export default function useTransitSymbologyLayer({ mapRef, searchCanton, + datasetId, isGraphExpanded, highlightedLineId, loadWithFallback, @@ -138,6 +139,7 @@ export default function useTransitSymbologyLayer({ }, [ mapRef, searchCanton, + datasetId, isGraphExpanded, showLineSymbology, selectedTransitModes, diff --git a/webmap-frontend/src/components/map/useTransitVolumesLayer.js b/webmap-frontend/src/components/map/useTransitVolumesLayer.js index 0a6cb82..2d3a474 100644 --- a/webmap-frontend/src/components/map/useTransitVolumesLayer.js +++ b/webmap-frontend/src/components/map/useTransitVolumesLayer.js @@ -7,6 +7,7 @@ export default function useTransitVolumesLayer({ mapRef, isGraphExpanded, searchCanton, + datasetId, timeRange, loadWithFallback, selectedTransitModes, @@ -616,7 +617,7 @@ export default function useTransitVolumesLayer({ return () => { removeLayers(); }; - }, [isGraphExpanded, searchCanton]); + }, [isGraphExpanded, searchCanton, datasetId]); // ----- update data on timeRange change ------------------------------------- diff --git a/webmap-frontend/src/components/map/useVolumeFlowLayers.js b/webmap-frontend/src/components/map/useVolumeFlowLayers.js index b44545f..dae2657 100644 --- a/webmap-frontend/src/components/map/useVolumeFlowLayers.js +++ b/webmap-frontend/src/components/map/useVolumeFlowLayers.js @@ -6,6 +6,7 @@ import { useFilters } from '../../context/FilterContext'; import { handle401 } from '../../utils/auth'; import { safeRemoveLayer, safeRemoveSource, setFilter } from './_lib/mapbox'; import { parsePipeList } from './_lib/pipeProps'; +import { CLICKABLE_ROAD_FILTER } from './_lib/mapboxFilters'; // Spider overlay source + layers (separate from the shared network-source) const SPIDER_SOURCE_ID = 'volume-flow-spider'; @@ -35,6 +36,39 @@ export function resetVolumeFlowOverlay(map) { } } +// Per-link daily volumes, keyed by `${datasetId}:${canton}`. Value is a +// Promise|null> so concurrent callers dedupe. The new v2 +// per-link geometry asset has no baked volume attribute, so VolumeFlow derives +// it from the link_volumes endpoint to hide links that carry no trips. +const linkVolumesCache = new Map(); + +function fetchLinkVolumes(datasetId, canton) { + const key = `${datasetId}:${canton}`; + if (linkVolumesCache.has(key)) return linkVolumesCache.get(key); + const url = `/backend/data/${datasetId}/link_volumes.json?canton=${encodeURIComponent(canton)}`; + const p = (async () => { + try { + let res = await fetch(url); + if (res.status === 401) { + const ok = await handle401(); + if (!ok) return null; + res = await fetch(url); + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (data.error) { console.warn('link_volumes error:', data.error); return null; } + const links = data.links || {}; + return new Map(Object.entries(links).map(([k, v]) => [String(k), Number(v)])); + } catch (err) { + console.warn('Failed to fetch link volumes:', err); + linkVolumesCache.delete(key); + return null; + } + })(); + linkVolumesCache.set(key, p); + return p; +} + export default function useVolumeFlowLayers({ mapRef, mapReady }) { const { isGraphExpanded } = useModule(); const { @@ -97,19 +131,24 @@ export default function useVolumeFlowLayers({ mapRef, mapReady }) { const fKeys = parsePipeList(f.properties.per_id_keys); const isTarget = fKeys.some(k => targetKeys.includes(k)); - let maxFlow = 0; + // Sum flow across every link on this segment (e.g. forward + reverse) + // so a merged segment's label is the total of its directions. When + // the target is "All", each direction's contribution comes from a + // different target link, so summing yields 4 + 3 = 7 rather than + // max(4, 3) = 4. + let segFlow = 0; for (const k of fKeys) { const vol = spiderMap.get(k); - if (vol !== undefined && vol > maxFlow) maxFlow = vol; + if (vol !== undefined) segFlow += vol; } - if (maxFlow > 0 || isTarget) { + if (segFlow > 0 || isTarget) { spiderFeatures.push({ ...f, id: idx, properties: { ...f.properties, - spider_flow: maxFlow, + spider_flow: segFlow, isTarget: isTarget || undefined, targetLinkId: isTarget ? displayLinkId : undefined, featureIndex: idx, @@ -387,12 +426,9 @@ export default function useVolumeFlowLayers({ mapRef, mapReady }) { map.setPaintProperty('network-layer', 'line-opacity', 0.4); } - // Apply VolumeFlow filter (car roads with >0 volume) - const vfFilter = ['all', - ['>=', ['index-of', ',car,', ['concat', ',', ['get', 'modes'], ',']], 0], - ['>', ['get', 'daily_avg_volume'], 0] - ]; - setFilter(map, ['network-layer', NETWORK_CLICK_LAYER], vfFilter); + // The clickable-road filter is owned by the dedicated volume effect + // below (which hides links with no trips once volumes load); useNetworkLayers + // sets a show-all filter on entry so links are clickable meanwhile. // Network not loaded yet if (!featureGeoJSON?.features || !map.getLayer(NETWORK_CLICK_LAYER)) return; @@ -457,6 +493,48 @@ export default function useVolumeFlowLayers({ mapRef, mapReady }) { return () => removeClickHandler(map); }, [mapReady, isGraphExpanded, clickedCanton, featureGeoJSON, mapRef, setVolumeFlowSegment, volumeFlowDirection, fetchAndCacheSpiders, renderForSelection, setVolumeFlowSelectedLink]); + // --- Volume filter: hide links with no trips --- + // The v2 per-link geometry asset carries no baked volume, so fetch per-link + // daily volumes, bake daily_avg_volume onto the shared network features (same + // object useNetworkLayers holds, so its own setData calls preserve it), and + // filter the network to volume > 0. Falls back to the show-all clickable + // filter if volumes are unavailable so the module still works. + useEffect(() => { + if (!mapReady || !mapRef.current) return; + if (isGraphExpanded !== 'VolumeFlow') return; + if (!clickedCanton || !datasetId || !featureGeoJSON?.features) return; + const map = mapRef.current; + + let cancelled = false; + (async () => { + const volMap = await fetchLinkVolumes(datasetId, clickedCanton); + if (cancelled || isGraphExpanded !== 'VolumeFlow') return; + if (!map.getLayer(NETWORK_CLICK_LAYER)) return; + + if (!volMap || volMap.size === 0) { + setFilter(map, ['network-layer', NETWORK_CLICK_LAYER], CLICKABLE_ROAD_FILTER); + return; + } + + const fc = featureGeoJSONRef.current; + if (fc?.features) { + for (const f of fc.features) { + const keys = parsePipeList(f.properties.per_id_keys); + let total = 0; + for (const k of keys) total += volMap.get(k) || 0; + f.properties.daily_avg_volume = total; + } + const src = map.getSource('network-source'); + if (src) src.setData(fc); + } + + setFilter(map, ['network-layer', NETWORK_CLICK_LAYER], + ['>', ['get', 'daily_avg_volume'], 0]); + })(); + + return () => { cancelled = true; }; + }, [mapReady, mapRef, isGraphExpanded, clickedCanton, datasetId, featureGeoJSON]); + // --- Effect 2: re-render when selected link changes (from sidebar) --- useEffect(() => { if (!mapReady || !mapRef.current || isGraphExpanded !== 'VolumeFlow') return; diff --git a/webmap-frontend/src/components/map/useZoneFlowLayers.js b/webmap-frontend/src/components/map/useZoneFlowLayers.js index a473eef..8698e61 100644 --- a/webmap-frontend/src/components/map/useZoneFlowLayers.js +++ b/webmap-frontend/src/components/map/useZoneFlowLayers.js @@ -6,7 +6,7 @@ import { useFilters } from '../../context/FilterContext'; import { handle401 } from '../../utils/auth'; import bboxCache from '../../utils/bboxCanton.json'; import { safeRemoveLayer, safeRemoveSource } from './_lib/mapbox'; -import { parsePipeList } from './_lib/pipeProps'; +import { parsePipeList, mergeSegmentsByGeometry } from './_lib/pipeProps'; const NETWORK_SOURCE_ID = 'zone-flows-network'; const FLOW_LAYER_ID = 'zone-flows-flow'; @@ -94,6 +94,11 @@ export default function useZoneFlowLayers({ mapRef, mapReady, loadWithFallback, try { const geo = await loadRef.current(`matsim/${canton}_merged_segments.geojson`); if (!geo?.features) return null; + // Backend serves these already merged; this only re-merges the + // stripped per-link format from the CDN/legacy fallback so + // applyFlowsToSource's per_id_keys matching works. No-op when the + // features already carry per_id_keys. + geo.features = mergeSegmentsByGeometry(geo.features); networkCacheRef.current[canton] = geo; return geo; } catch (err) { diff --git a/webmap-frontend/src/components/matsim/LinkSpeedsModule.jsx b/webmap-frontend/src/components/matsim/LinkSpeedsModule.jsx index 24b5a77..97be19a 100644 --- a/webmap-frontend/src/components/matsim/LinkSpeedsModule.jsx +++ b/webmap-frontend/src/components/matsim/LinkSpeedsModule.jsx @@ -140,6 +140,8 @@ const LinkSpeedsModule = ({ featureTableRef }) => { linkSpeedsSelected, setFeatureSelection, setSelectedNetworkFeature, + linkSpeedsSelectedLink, + setLinkSpeedsSelectedLink, } = useSelection(); const { timeRange, setTimeRange, @@ -430,6 +432,26 @@ const LinkSpeedsModule = ({ featureTableRef }) => {
)} + {/* Per-link selector — only for a merged (single-line, low-zoom) + selection bundling more than one link. Split-layer (zoomed-in, + per-direction) selections already isolate one direction, so no + dropdown is offered there. */} + {linkSpeedsSelected && !linkSpeedsSelected.isSplit + && linkSpeedsSelected.allKeys?.length > 1 && ( +
+ + +
+ )} + {/* Selected-link detail */} {linkSpeedsSelected && (
diff --git a/webmap-frontend/src/components/matsim/NetworkModule.jsx b/webmap-frontend/src/components/matsim/NetworkModule.jsx index 636e65d..039770f 100644 --- a/webmap-frontend/src/components/matsim/NetworkModule.jsx +++ b/webmap-frontend/src/components/matsim/NetworkModule.jsx @@ -1,40 +1,65 @@ import React, { useCallback, useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; import SegmentAttributesTable from "./SegmentAttributesTable"; import FeatureTable from "../table/FeatureTable"; import { useTableRowBuilder } from "../../hooks/useTableRowBuilder"; import { buildSelectionPayload } from "../table/_lib/rowSearch"; +import { parsePipeList } from "../map/_lib/pipeProps"; import { useData } from "../../context/DataContext"; import { useFilters } from "../../context/FilterContext"; import { useSelection } from "../../context/SelectionContext"; import { useModule } from "../../context/ModuleContext"; -import { useFileContext } from "../../FileContext"; -import { useLoadWithFallback } from "../../utils/useLoadWithFallback"; +import "./VolumeFlowModule.css"; -// Modes excluded from the canton mode-filter dropdown — these are aggregate -// or non-road modes the user can't usefully filter on at the network level. -const EXCLUDED_MODES = ["car_passenger", "truck", "rail", "other", "pt", "taxi"]; +// Modes hidden from the network filter: car_passenger/train/taxi/truck ride +// along the same links as their primary mode (car/rail), so filtering on them +// isn't useful at the network level. +const EXCLUDED_MODES = new Set([ + "car_passenger", "train", "taxi", "truck", +]); const NetworkModule = ({ featureTableRef }) => { - const { dataURL, isFeatureTableOpen, featureGeoJSON, setTableFilterQuery } = useData(); + const { isFeatureTableOpen, featureGeoJSON, setTableFilterQuery } = useData(); const { selectedNetworkModes, setSelectedNetworkModes } = useFilters(); - const { clickedCanton: canton, selectedNetworkFeature, setSelectedNetworkFeature, setFeatureSelection } = useSelection(); + const { + clickedCanton: canton, + selectedNetworkFeature, setSelectedNetworkFeature, setFeatureSelection, + networkSelectedLink, setNetworkSelectedLink, + } = useSelection(); const { isGraphExpanded: selectedGraph } = useModule(); - const { fileMap } = useFileContext(); - const loadWithFallback = useLoadWithFallback(dataURL); - // Per-canton mode list — drives the multi-select dropdown. - const { data: modesByCanton = {} } = useQuery({ - queryKey: ['modes-by-canton', dataURL, fileMap.size], - queryFn: () => loadWithFallback("modes_by_canton.json"), - }); + // Per-link selection state derived from the current selection. + // isSplit — a per-direction (zoomed-in) selection; no dropdown, the + // attribute table shows just that direction's link(s). + // allKeys — every link on the merged segment (drives the dropdown). + // linkFilter — which links the attribute table shows: the split direction, + // the dropdown pick, or null (= all links / "All"). + const selProps = selectedNetworkFeature?.[0]; + const isSplit = !!selProps?.ls_arrow; + const allKeys = useMemo(() => parsePipeList(selProps?.per_id_keys), [selProps]); + const linkFilter = isSplit + ? parsePipeList(selProps?.ls_link_ids) + : (networkSelectedLink ? [networkSelectedLink] : null); + // Available modes = the distinct transport modes actually present on the + // canton's network links. The enriched merged_segments geometry carries the + // real per-link `modes` (comma-joined, e.g. "car,car_passenger,taxi,truck"), + // so we union them across the loaded features — far more accurate than the + // old trip-based modes_by_canton list (which only knew car/pt/walk/bike/ + // car_passenger, then got further trimmed to car/walk/bike). const availableModes = useMemo(() => { - if (canton && modesByCanton[canton]) { - return modesByCanton[canton].filter((mode) => !EXCLUDED_MODES.includes(mode)); + const feats = featureGeoJSON?.features; + if (!feats || feats.length === 0) return []; + const set = new Set(); + for (const f of feats) { + const m = f?.properties?.modes; + if (!m) continue; + for (const part of String(m).split(",")) { + const t = part.trim(); + if (t && !EXCLUDED_MODES.has(t)) set.add(t); + } } - return []; - }, [canton, modesByCanton]); + return Array.from(set).sort(); + }, [featureGeoJSON]); const handleModeChange = (event) => { const selectedOptions = Array.from(event.target.selectedOptions).map((o) => o.value); @@ -119,8 +144,26 @@ const NetworkModule = ({ featureTableRef }) => {

)} + {/* Per-link selector — only for a merged (single-line, low-zoom) selection + bundling more than one link. Split (zoomed-in, per-direction) selections + already isolate one direction, so no dropdown there. */} + {selectedNetworkFeature && !isSplit && allKeys.length > 1 && ( +
+ + +
+ )} + {selectedNetworkFeature && ( - + )} )} diff --git a/webmap-frontend/src/components/matsim/SegmentAttributesTable.jsx b/webmap-frontend/src/components/matsim/SegmentAttributesTable.jsx index 2e1d955..b4ebf28 100644 --- a/webmap-frontend/src/components/matsim/SegmentAttributesTable.jsx +++ b/webmap-frontend/src/components/matsim/SegmentAttributesTable.jsx @@ -7,20 +7,36 @@ const fmtNum = (v) => { }; const allEqual = (arr) => (arr.length === 0 ? true : arr.every((x) => x === arr[0])); -const SegmentAttributesTable = ({ propertiesList, selectedGraph, filteredVolume }) => { +const SegmentAttributesTable = ({ propertiesList, selectedGraph, filteredVolume, linkFilter }) => { const [isCollapsed, setIsCollapsed] = useState(false); if (!propertiesList || propertiesList.length === 0) return null; const top = propertiesList[0] || {}; - + // Parse pipe-separated strings into arrays - const keys = (top.per_id_keys || "").split("|").filter(Boolean); - const capacities = (top.per_id_capacities || "").split("|").filter(Boolean); - const lengths = (top.per_id_lengths || "").split("|").filter(Boolean); - const freespeeds = (top.per_id_freespeeds || "").split("|").filter(Boolean); - const daily_avgs = (top.per_id_daily_avgs || "").split("|").filter(Boolean); - const permlanes = (top.per_id_permlanes || "").split("|").filter(Boolean); - + let keys = (top.per_id_keys || "").split("|").filter(Boolean); + let capacities = (top.per_id_capacities || "").split("|").filter(Boolean); + let lengths = (top.per_id_lengths || "").split("|").filter(Boolean); + let freespeeds = (top.per_id_freespeeds || "").split("|").filter(Boolean); + let daily_avgs = (top.per_id_daily_avgs || "").split("|").filter(Boolean); + let permlanes = (top.per_id_permlanes || "").split("|").filter(Boolean); + + // Narrow the per-link arrays to a chosen subset (the Link ID dropdown or a + // per-direction split selection). null/empty → show every link on the segment. + if (Array.isArray(linkFilter) && linkFilter.length) { + const keep = new Set(linkFilter.map(String)); + const idxs = keys.map((k, i) => (keep.has(String(k)) ? i : -1)).filter((i) => i >= 0); + if (idxs.length) { + const pick = (arr) => idxs.map((i) => arr[i]); + keys = pick(keys); + capacities = pick(capacities); + lengths = pick(lengths); + freespeeds = pick(freespeeds); + daily_avgs = pick(daily_avgs); + permlanes = pick(permlanes); + } + } + // Build array of objects for easier processing (similar to old per_id entries) const perIdEntries = keys.map((id, index) => [ id, @@ -105,6 +121,24 @@ const SegmentAttributesTable = ({ propertiesList, selectedGraph, filteredVolume {!isCollapsed && ( + {/* Link id(s) on the segment (respects the dropdown / split filter) */} + + + + + + {/* Road volume (total) — highlighted second row in Volumes */} + {selectedGraph === "Volumes" && ( + + + + + )} + {/* Per-direction fields first (deduped) */} {renderDedupRow("Length", "length", { unit: "m" })} {renderDedupRow("Free Speed", "freespeed", { unit: "km/h" })} @@ -117,24 +151,11 @@ const SegmentAttributesTable = ({ propertiesList, selectedGraph, filteredVolume - {selectedGraph === "Volumes" && ( - <> - {perIdEntries.length > 1 && - renderDedupRow("Volume (per direction)", "daily_avg_volume", { - useFilteredVolume: hasFiltered, - }) - } - - - - - - - )} + {selectedGraph === "Volumes" && perIdEntries.length > 1 && + renderDedupRow("Volume (per direction)", "daily_avg_volume", { + useFilteredVolume: hasFiltered, + }) + } diff --git a/webmap-frontend/src/components/matsim/VolumesModule.jsx b/webmap-frontend/src/components/matsim/VolumesModule.jsx index f30dcfc..5549782 100644 --- a/webmap-frontend/src/components/matsim/VolumesModule.jsx +++ b/webmap-frontend/src/components/matsim/VolumesModule.jsx @@ -10,16 +10,23 @@ import useLinePolygon from "../../hooks/useLinePolygon"; import useDrawPolygons from "../../hooks/useDrawPolygons"; import { computeBoundaryFlow } from "../../utils/boundaryFlow"; import { buildSelectionPayload } from "../table/_lib/rowSearch"; +import { parsePipeList } from "../map/_lib/pipeProps"; import { useData } from "../../context/DataContext"; import { useFilters } from "../../context/FilterContext"; import { useSelection } from "../../context/SelectionContext"; import { useModule } from "../../context/ModuleContext"; import { useMap } from "../../context/MapContext"; +import "./VolumeFlowModule.css"; + +// The Volumes map always renders car links only (useNetworkLayers applies a +// car filter on entry, with major-roads layered on top), so the feature table +// is car-only too. Module-level constant keeps a stable reference across +// renders (a fresh `['car']` each render would bust the table's row useMemo). +const VOLUMES_TABLE_MODES = ['car']; const VolumesModule = ({ featureTableRef }) => { const { isFeatureTableOpen, featureGeoJSON, setTableFilterQuery } = useData(); const { - selectedNetworkModes, showMajorRoadsOnly, setShowMajorRoadsOnly, timeRange, setTimeRange, } = useFilters(); @@ -28,6 +35,7 @@ const VolumesModule = ({ featureTableRef }) => { selectedNetworkFeature, setSelectedNetworkFeature, triggerVisualize, setFeatureSelection, + networkSelectedLink, setNetworkSelectedLink, } = useSelection(); const { isGraphExpanded } = useModule(); const { mapRef, drawRef, labelSize, setLabelSize, setMapLoading } = useMap(); @@ -36,6 +44,23 @@ const VolumesModule = ({ featureTableRef }) => { const [filteredVolume, setFilteredVolume] = useState(null); + // Per-link selection derived from the current selection (mirrors NetworkModule). + // isSplit — per-direction (zoomed-in) selection; no dropdown. + // allKeys — every link on the merged segment (drives the dropdown). + // effectiveLinks — links the histogram charts; >1 → summed (aggregate). + // attrLinkFilter — links the attribute table shows (null = all / "All"). + const selProps = selectedNetworkFeature?.[0]; + const isSplit = !!selProps?.ls_arrow; + const allKeys = useMemo(() => parsePipeList(selProps?.per_id_keys), [selProps]); + const effectiveLinks = useMemo(() => { + if (isSplit) return parsePipeList(selProps?.ls_link_ids); + if (networkSelectedLink) return [networkSelectedLink]; + return allKeys; + }, [isSplit, selProps, networkSelectedLink, allKeys]); + const attrLinkFilter = isSplit + ? parsePipeList(selProps?.ls_link_ids) + : (networkSelectedLink ? [networkSelectedLink] : null); + // Polygon selection const handlePolygonChange = useCallback(() => { setSelectedNetworkFeature?.(null); @@ -105,9 +130,18 @@ const VolumesModule = ({ featureTableRef }) => { }); const activeTableRows = useMemo(() => { - if (!polygonFeatures.length || !isFeatureTableOpen) return tableRows; - return tableRows.filter(row => polygonFeaturesSet.has(row.feature)); - }, [tableRows, polygonFeaturesSet, polygonFeatures.length, isFeatureTableOpen]); + let rows = tableRows; + // Mirror the map: with "major roads only" on, the map shows (and only fetches + // volumes for) capacity > 1200 segments, so the table lists just those too — + // otherwise minor roads would appear with 0 volume from the major-only fetch. + if (showMajorRoadsOnly) { + rows = rows.filter(row => Number(row.feature?.properties?.capacity) > 1200); + } + if (polygonFeatures.length && isFeatureTableOpen) { + rows = rows.filter(row => polygonFeaturesSet.has(row.feature)); + } + return rows; + }, [tableRows, polygonFeaturesSet, polygonFeatures.length, isFeatureTableOpen, showMajorRoadsOnly]); const handleTableRowSelect = useCallback( (row) => { @@ -143,7 +177,7 @@ const VolumesModule = ({ featureTableRef }) => { tableId="volumes-feature-table" rows={activeTableRows} geojson={rowsReady ? null : featureGeoJSON} - selectedModes={selectedNetworkModes} + selectedModes={VOLUMES_TABLE_MODES} onRowClick={handleTableRowSelect} onSelectCoords={handleSelectCoords} height={"55vh"} @@ -272,26 +306,37 @@ const VolumesModule = ({ featureTableRef }) => { )} + {/* Per-link selector — only for a merged (single-line, low-zoom) selection + bundling more than one link. Split (per-direction) selections isolate one + direction already, so no dropdown there. */} + {selectedNetworkFeature && !polygonAggregate && !isSplit && allKeys.length > 1 && ( +
+ + +
+ )} + {selectedNetworkFeature && !polygonAggregate && ( )} {selectedNetworkFeature && !polygonAggregate ? ( { - // Extract link IDs from pipe-separated per_id_keys - const perIdKeys = selectedNetworkFeature[0]?.per_id_keys; - if (perIdKeys && typeof perIdKeys === 'string') { - const ids = perIdKeys.split("|").filter(Boolean); - return ids.length ? ids : [String(selectedNetworkFeature[0]?.id ?? '')]; - } - // Fallback to feature id if per_id_keys not available - return [String(selectedNetworkFeature[0]?.id ?? '')]; - })()} + linkId={effectiveLinks} + aggregate={effectiveLinks.length > 1} triggerVisualize={triggerVisualize} canton={canton} timeRange={timeRange} diff --git a/webmap-frontend/src/components/plots/DestinationZones.jsx b/webmap-frontend/src/components/plots/DestinationZones.jsx index 8bc6292..36bdcdb 100644 --- a/webmap-frontend/src/components/plots/DestinationZones.jsx +++ b/webmap-frontend/src/components/plots/DestinationZones.jsx @@ -40,14 +40,6 @@ const REVERSE_CANTON = Object.entries(cantonAlias).reduce((acc, [internal, displ return acc; }, {}); -// Most destination_data/*.json files are named with the canton's internal -// NAME (e.g. "Zurich.json"), but St. Gallen's file is named with its display -// form "St. Gallen.json". Map any oddballs here. -const CANTON_TO_FILENAME = { - StGallen: "St. Gallen", -}; -const fileNameFor = (canton) => CANTON_TO_FILENAME[canton] || canton; - // Mirrors the +/- CollapseToggle from LinkSpeedsModule so destination cards // expand/collapse the same way as other module cards. const CollapseToggle = ({ collapsed, onToggle }) => ( @@ -83,6 +75,7 @@ const DestinationZones = ({ canton, onTotalOutflowChange, timeRange, setTimeRang const [isPlotCollapsed, setIsPlotCollapsed] = useState(false); const { + datasetId, destinationHoveredCanton, setDestinationHoveredCanton, destinationSelectedCanton, setDestinationSelectedCanton, } = useData(); @@ -101,9 +94,12 @@ const DestinationZones = ({ canton, onTotalOutflowChange, timeRange, setTimeRang setDestinationSelectedCanton(null); }, [canton, setDestinationSelectedCanton]); + // Derived from the backend `destination_zones.json` provider (per-hub-canton + // outflow/inflow by mode/purpose/15-min bin). datasetId in the key so a + // dataset switch refetches instead of serving the previous dataset's cache. const { data: plotData } = useQuery({ - queryKey: ["destination-zones", canton], - queryFn: () => loadWithFallback(`destination_data/${fileNameFor(canton)}.json`), + queryKey: ["destination-zones", canton, datasetId], + queryFn: () => loadWithFallback(`destination_zones.json?canton=${encodeURIComponent(canton)}`), enabled: !!canton, }); diff --git a/webmap-frontend/src/components/sidebar/RightSidebar.jsx b/webmap-frontend/src/components/sidebar/RightSidebar.jsx index a31c9e4..dfa5770 100644 --- a/webmap-frontend/src/components/sidebar/RightSidebar.jsx +++ b/webmap-frontend/src/components/sidebar/RightSidebar.jsx @@ -46,6 +46,9 @@ import PolygonTripsModule from "../matsim/PolygonTripsModule"; import { resetNodeFlowsOverlay } from "../map/useNodeFlowLayers"; import { resetVolumeFlowOverlay } from "../map/useVolumeFlowLayers"; +// Reactive accessor for the current drawn polygons (shared draw tool) +import useDrawPolygons from "../../hooks/useDrawPolygons"; + // Module labels for the header const MODULE_LABELS = { Choropleth: "Choropleth", @@ -88,6 +91,10 @@ const RightSidebar = () => { const featureTableRef = useRef(null); const transitFeatureTableRef = useRef(null); + // Track drawn polygons so the clear button only appears when there's something to clear + const drawnPolygons = useDrawPolygons({ mapRef, drawRef, isGraphExpanded, activeModule: isGraphExpanded }); + const hasPolygons = drawnPolygons.length > 0; + const handleTotalOutflowChange = (outflowData) => { setDestinationOutflowData(outflowData); setDestinationData?.(outflowData); @@ -189,16 +196,18 @@ const RightSidebar = () => { New Polygon - + {hasPolygons && ( + + )} )} diff --git a/webmap-frontend/src/components/table/FeatureTable.jsx b/webmap-frontend/src/components/table/FeatureTable.jsx index 640998e..51e1cf4 100644 --- a/webmap-frontend/src/components/table/FeatureTable.jsx +++ b/webmap-frontend/src/components/table/FeatureTable.jsx @@ -78,11 +78,15 @@ const FeatureTable = forwardRef( const tableRows = useMemo(() => { let filtered = baseRows.filter((r) => modeMatches(r.modes, selectedModes)); - // Major-roads filter: totalCapacity > 1200 (sum across directions) + // Major-roads filter: match the map's predicate exactly. The map tests the + // segment's representative `capacity` (['>', ['get','capacity'], 1200]), + // NOT the summed-across-directions totalCapacity — the sum let sub-1200 + // segments (e.g. 700+700) through that the map hides, so the table showed + // links the map had filtered out. if (showMajorRoadsOnly) { filtered = filtered.filter((r) => { - const totalCap = Number(r.totalCapacity); - return Number.isFinite(totalCap) && totalCap > 1200; + const cap = Number(r.featureProps?.capacity ?? r.capacity); + return Number.isFinite(cap) && cap > 1200; }); } return filtered.slice(0, maxRows); @@ -127,12 +131,16 @@ const FeatureTable = forwardRef( tableRows, }); - // Search → tableFilterQuery context (drives the map-side filter) + // Search → tableFilterQuery context (drives the map-side filter). Runs + // after useDataTableSearch so the DT instance already reflects the applied + // search; emits the matched-row id set the map mirrors directly. useTableFilterQuerySync({ + dtRef, searchCol, debouncedSearch, searchText, dtColumns, + tableRows, setTableFilterQuery, }); diff --git a/webmap-frontend/src/components/table/_lib/buildRows.js b/webmap-frontend/src/components/table/_lib/buildRows.js index f32d3a5..b6d82eb 100644 --- a/webmap-frontend/src/components/table/_lib/buildRows.js +++ b/webmap-frontend/src/components/table/_lib/buildRows.js @@ -162,9 +162,10 @@ export const buildRowsFromGeojson = (geojson, selectedGraph = null) => { const arrow = arrows[index] || null; const direction = directions[index] || null; - // For TransitVolumes, use directional total volumes; otherwise use daily_avgs + // Total Daily Volume column. let totalVol; if (selectedGraph === 'TransitVolumes') { + // TransitVolumes: directional total volumes baked by useTransitVolumesLayer. if (arrow === '←') { totalVol = props.total_left; } else if (arrow === '→') { @@ -172,7 +173,21 @@ export const buildRowsFromGeojson = (geojson, selectedGraph = null) => { } else { totalVol = props.total_volume; } + } else if (selectedGraph === 'Volumes') { + // Volumes: full-day directional total (left_total/right_total), derived + // from the backend traffic volumes by useNetworkLayers. Mirrors the + // directional Filtered Volume (left_sum/right_sum) so the two columns + // stay consistent (Total ≥ Filtered, equal at full window) instead of + // mixing a per-link total against a directional filtered value. + if (arrow === '←') { + totalVol = num(props.left_total); + } else if (arrow === '→') { + totalVol = num(props.right_total); + } else { + totalVol = num(daily_avgs[index]); + } } else { + // Network (no time-windowed volumes): per-link daily average. totalVol = num(daily_avgs[index]); } diff --git a/webmap-frontend/src/components/transit/TransitModule.jsx b/webmap-frontend/src/components/transit/TransitModule.jsx index 704ff67..00450cc 100644 --- a/webmap-frontend/src/components/transit/TransitModule.jsx +++ b/webmap-frontend/src/components/transit/TransitModule.jsx @@ -33,7 +33,7 @@ const TransitModule = ({ featureTableRef }) => { } = useSelection(); const { highlightedLineId, setHighlightedLineId, - setHighlightedRouteIds, setHoveredRouteId, + setHighlightedRouteIds, } = useChoropleth(); const { isGraphExpanded } = useModule(); const { mapRef, drawRef } = useMap(); @@ -185,22 +185,18 @@ const TransitModule = ({ featureTableRef }) => { [handleTableRowSelect] ); - // Prefetch transit routes for mode checking - const { data: transitRoutes } = useQuery({ - queryKey: ['transit-routes-geojson'], - queryFn: () => loadWithFallback("matsim/transit/routes/transit_routes.geojson"), - staleTime: Infinity, - }); - - // If a new line is selected and its mode is not in the filter, reset to "all" (was useEffect) + // If a new line is selected and its mode is not in the filter, reset to "all" (was useEffect). + // The line's mode comes from the selected stop's `lines` (each carries `mode`), + // so there's no need to load the full transit_routes asset here. const prevHighlightedLineRef = useRef(highlightedLineId); if (prevHighlightedLineRef.current !== highlightedLineId) { prevHighlightedLineRef.current = highlightedLineId; - if (highlightedLineId && Array.isArray(selectedTransitModes) && !selectedTransitModes.includes("all") && transitRoutes) { - const feat = transitRoutes?.features?.find( - (f) => String(f?.properties?.line_id) === String(highlightedLineId) - ); - const mode = feat?.properties?.mode && String(feat.properties.mode); + if (highlightedLineId && Array.isArray(selectedTransitModes) && !selectedTransitModes.includes("all")) { + const activeLines = polygonSelection?.lines ?? selectedTransitStop?.lines; + const match = Array.isArray(activeLines) + ? activeLines.find((l) => String(l?.line_id) === String(highlightedLineId)) + : null; + const mode = match?.mode && String(match.mode); if (mode && !selectedTransitModes.includes(mode)) { setSelectedTransitModes(["all"]); } @@ -314,7 +310,7 @@ const TransitModule = ({ featureTableRef }) => { ...(filteredStopVolumes ?? {}) }} highlightedLineId={highlightedLineId} - onLineClick={(lineId, routeIds) => { + onLineClick={(lineId) => { if (lineId) { // Determine mode of the clicked line from the current stop's lines const allLines = Array.isArray(selectedTransitStop?.lines) ? selectedTransitStop.lines : []; @@ -326,9 +322,8 @@ const TransitModule = ({ featureTableRef }) => { } } setHighlightedLineId(lineId); - setHighlightedRouteIds(filterRoutesByDirection(routeIds, selectedDirection)); + setHighlightedRouteIds(lineId ? [lineId] : []); }} - onRouteHover={setHoveredRouteId} selectedDirection={selectedDirection} setSelectedDirection={setSelectedDirection} /> @@ -354,7 +349,7 @@ const TransitModule = ({ featureTableRef }) => { ...(polygonFilteredVolumes ?? {}) }} highlightedLineId={highlightedLineId} - onLineClick={(lineId, routeIds) => { + onLineClick={(lineId) => { if (lineId) { const allLines = Array.isArray(polygonSelection?.lines) ? polygonSelection.lines : []; const match = allLines.find(l => String(l?.line_id) === String(lineId)); @@ -364,9 +359,8 @@ const TransitModule = ({ featureTableRef }) => { } } setHighlightedLineId(lineId); - setHighlightedRouteIds(filterRoutesByDirection(routeIds, selectedDirection)); + setHighlightedRouteIds(lineId ? [lineId] : []); }} - onRouteHover={setHoveredRouteId} selectedDirection={selectedDirection} setSelectedDirection={setSelectedDirection} /> diff --git a/webmap-frontend/src/components/transit/TransitStopAttributesTable.jsx b/webmap-frontend/src/components/transit/TransitStopAttributesTable.jsx index d39e9c4..3da5340 100644 --- a/webmap-frontend/src/components/transit/TransitStopAttributesTable.jsx +++ b/webmap-frontend/src/components/transit/TransitStopAttributesTable.jsx @@ -7,32 +7,29 @@ const DIRECTION_OPTIONS = [ { value: 'return', label: 'Return' } ]; -const TransitStopAttributesTable = ({ properties, onLineClick, highlightedLineId, onRouteHover, selectedDirection, setSelectedDirection }) => { +const TransitStopAttributesTable = ({ properties, onLineClick, highlightedLineId, selectedDirection, setSelectedDirection }) => { if (!properties) return null; - + const { name, modes_list, lines, boardings, alightings, total } = properties; - - const [hoveredRoute, setHoveredRoute] = useState(null); - const [showRoutes, setShowRoutes] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(false); - + const groupedLines = lines.reduce((acc, line) => { if (!acc[line.line_id]) acc[line.line_id] = []; acc[line.line_id].push(line); return acc; }, {}); - - const numRoutes = lines?.length || 0; + const numLines = Object.keys(groupedLines).length; - + const activeBadge = highlightedLineId; - + + // Selection is keyed purely off line_id — the duckdb boarding data has no + // per-stop route_id, so clicking a line badge highlights the whole line. const handleBadgeClick = (line_id) => { const isActive = highlightedLineId === line_id; - const routeIds = groupedLines[line_id].map(route => route.route_id); - if (onLineClick) { - onLineClick(isActive ? null : line_id, isActive ? [] : routeIds); + onLineClick(isActive ? null : line_id); } }; @@ -63,8 +60,7 @@ const TransitStopAttributesTable = ({ properties, onLineClick, highlightedLineId - - {/* diff --git a/webmap-frontend/src/context/SelectionContext.jsx b/webmap-frontend/src/context/SelectionContext.jsx index bbf32c9..11ae948 100644 --- a/webmap-frontend/src/context/SelectionContext.jsx +++ b/webmap-frontend/src/context/SelectionContext.jsx @@ -31,6 +31,12 @@ export const SelectionProvider = ({ children }) => { const [volumeFlowSelectedLink, setVolumeFlowSelectedLink] = useState(null); const [linkSpeedsSelected, setLinkSpeedsSelected] = useState(null); + const [linkSpeedsSelectedLink, setLinkSpeedsSelectedLink] = useState(null); + + // Per-link dropdown selection for the Network / Volumes modules (shared — only + // one of those modules is active at a time). Reset on new segment / module + // switch by useNetworkSplitLayers, mirroring linkSpeedsSelectedLink. + const [networkSelectedLink, setNetworkSelectedLink] = useState(null); const [zoneFlowDestCanton, setZoneFlowDestCanton] = useState(null); @@ -46,6 +52,8 @@ export const SelectionProvider = ({ children }) => { volumeFlowSegment, setVolumeFlowSegment, volumeFlowSelectedLink, setVolumeFlowSelectedLink, linkSpeedsSelected, setLinkSpeedsSelected, + linkSpeedsSelectedLink, setLinkSpeedsSelectedLink, + networkSelectedLink, setNetworkSelectedLink, zoneFlowDestCanton, setZoneFlowDestCanton, }), [ clickedCanton, @@ -53,7 +61,8 @@ export const SelectionProvider = ({ children }) => { selectedTransitLink, selectedTransitStop, visualizeLinkId, visualizeNonce, hoveredMatrixCell, volumeFlowSegment, volumeFlowSelectedLink, - linkSpeedsSelected, + linkSpeedsSelected, linkSpeedsSelectedLink, + networkSelectedLink, zoneFlowDestCanton, ]); diff --git a/webmap-frontend/src/hooks/useDataTable.js b/webmap-frontend/src/hooks/useDataTable.js index 4b2e7ce..592dc52 100644 --- a/webmap-frontend/src/hooks/useDataTable.js +++ b/webmap-frontend/src/hooks/useDataTable.js @@ -411,15 +411,32 @@ export function useDataTableSearch({ * Mirror the toolbar search to the `tableFilterQuery` context the module * map-filter hooks consume. Emits `null` on blank input so downstream * filters reset to the full set. + * + * Besides the legacy `{ column, value }` (still read by the TransitVolumes / + * LinkSpeeds map filters), the payload now carries the *set of rows DataTables + * actually matched*, reduced to the map's id spaces: + * - `fids` — unique `tableId`s. The network GeoJSON source uses + * `generateId`, so `feature.id === row.tableId`; a + * `["match", ["id"], fids, …]` reproduces the table 1:1 with no + * re-implementation of the search in Mapbox-expression syntax. + * - `linkIds` — unique raw link ids (`directionId`), for split-layer modules + * that build their own per-direction features off `per_id_*`. + * Computed from the DT instance *after* the search/draw has been applied (this + * hook is invoked after `useDataTableSearch` in FeatureTable, so the instance + * already reflects the current search — including the numeric-comparison ext + * filter), so the map filter can never drift from what the table shows. */ export function useTableFilterQuerySync({ + dtRef, searchCol, debouncedSearch, searchText, dtColumns, + tableRows, setTableFilterQuery, }) { - // effect:audited — syncs DataTables search state to parent map filter query + // effect:audited — syncs DataTables search state + matched-row id set to the + // map-filter query context. useEffect(() => { const raw = (searchText || "").trim(); if (!raw) { @@ -431,7 +448,31 @@ export function useTableFilterQuerySync({ if (Number.isInteger(searchCol) && searchCol >= 0) { column = dtColumns[searchCol]?.data || null; } - setTableFilterQuery?.({ column, value: raw }); + + // Reduce the matched rows (across all pages, incl. the ext comparison + // filter) to the map's feature-id / link-id spaces. + let fids = null; + let linkIds = null; + try { + const dt = dtRef?.current; + if (dt && dt.settings && dt.settings()[0]) { + const data = dt.rows({ search: "applied" }).data(); + const fidSet = new Set(); + const linkSet = new Set(); + for (let i = 0; i < data.length; i++) { + const r = data[i]; + if (r == null) continue; + if (Number.isInteger(r.tableId)) fidSet.add(r.tableId); + if (r.directionId != null) linkSet.add(String(r.directionId)); + } + fids = Array.from(fidSet); + linkIds = Array.from(linkSet); + } + } catch { + // DT instance mid-teardown — fall back to the legacy query-only payload. + } + + setTableFilterQuery?.({ column, value: raw, fids, linkIds }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearch, searchCol]); + }, [debouncedSearch, searchCol, tableRows]); }
{keys.length > 1 ? "Link IDs" : "Link ID"}{keys.length ? keys.join(", ") : "-"}
Road Volume + {hasFiltered + ? `${fmtNum(filteredTotal)} vehicles` + : `${fmtNum(top.daily_avg_volume)} vehicles/day`} +
{fmtNum(top.capacity)}
Avg Daily Volume (total) - {hasFiltered - ? `${fmtNum(filteredTotal)} vehicles` - : `${fmtNum(top.daily_avg_volume)} vehicles/day`} -
Modes
Mode{modes_list?.join(", ")}
Lines{numLines}
Routes{numRoutes}
Volumes +
Volumes
Boardings
@@ -93,42 +89,6 @@ const TransitStopAttributesTable = ({ properties, onLineClick, highlightedLineId ))}
- - {/* Toggle button to show/hide routes */} - {activeBadge && ( - - )} - - {/* Conditional route list */} - {showRoutes && activeBadge && Array.isArray(groupedLines[activeBadge]) && ( -
    - {groupedLines[activeBadge].map((route, i) => ( -
  • { - setHoveredRoute(route.route_id); - onRouteHover?.(route.route_id); - }} - onMouseLeave={() => { - setHoveredRoute(null); - onRouteHover?.(null); - }} - > -
  • - ))} -
- )}