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
)}
+ {/* 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 && (
+