From 7e6cf87367b7b65602930874a5c868ae05f2b6a8 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Tue, 26 May 2026 11:33:50 +0200 Subject: [PATCH 01/10] feat(los): add Line-of-Sight tool with live cursor tracking Adds a new "Line of Sight" entry to the Measure menu. After placing an observer with the first click, the LoS recomputes on every pointermove against the Mapbox-RGB terrain tile source and renders the visible segment in green, the blocked segment as a dashed red line and the first blocker as a red diamond. Distance, eye-height delta and first-blocker distance are shown in the OSD. Range is clamped to 10 km (clip marker drawn at the cap); the second click finalises the LoS and leaves it on the map, so multiple Line-of-Sights can coexist in a session. Earth curvature and atmospheric refraction (k=0.13) are applied unconditionally to keep results realistic at the relevant distances. The command is gated on terrain availability and WebGL2 support (the latter is a prerequisite for the planned Area-of-Sight feature and is checked here for consistency). Observer/target heights default to 1.70 m AGL; an editor for these plus persistence will follow in a separate change. --- src/renderer/components/Toolbar.js | 3 +- src/renderer/components/map/Map.js | 2 + src/renderer/model/CommandRegistry.js | 2 + .../model/commands/LineOfSightCommands.js | 64 +++++ .../ol/interaction/line-of-sight/compute.js | 95 +++++++ .../ol/interaction/line-of-sight/index.js | 238 ++++++++++++++++++ .../ol/interaction/line-of-sight/style.js | 44 ++++ 7 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 src/renderer/model/commands/LineOfSightCommands.js create mode 100644 src/renderer/ol/interaction/line-of-sight/compute.js create mode 100644 src/renderer/ol/interaction/line-of-sight/index.js create mode 100644 src/renderer/ol/interaction/line-of-sight/style.js diff --git a/src/renderer/components/Toolbar.js b/src/renderer/components/Toolbar.js index e2c005c0..3cb8c2a7 100644 --- a/src/renderer/components/Toolbar.js +++ b/src/renderer/components/Toolbar.js @@ -52,7 +52,8 @@ export const Toolbar = () => { commandRegistry.command('MEASURE_DISTANCE'), commandRegistry.command('MEASURE_AREA'), commandRegistry.command('MEASURE_CIRCLE'), - commandRegistry.command('ELEVATION_PROFILE') + commandRegistry.command('ELEVATION_PROFILE'), + commandRegistry.command('LINE_OF_SIGHT') ] const replicationCommands = [ diff --git a/src/renderer/components/map/Map.js b/src/renderer/components/map/Map.js index 4ba758e1..d499332e 100644 --- a/src/renderer/components/map/Map.js +++ b/src/renderer/components/map/Map.js @@ -16,6 +16,7 @@ import registerGraticules from './graticules' import measure from '../../ol/interaction/measure' import shapeInteraction from '../../ol/interaction/shape-interaction' import elevationProfile from '../../ol/interaction/elevation-profile' +import lineOfSight from '../../ol/interaction/line-of-sight' import print from '../print' import './Map.css' import './ScaleLine.css' @@ -83,6 +84,7 @@ export const Map = () => { measure({ services, map }) shapeInteraction({ services, map }) elevationProfile({ services, map }) + lineOfSight({ services, map }) // Expose a function to query the current map resolution. services.getMapResolution = () => map.getView().getResolution() diff --git a/src/renderer/model/CommandRegistry.js b/src/renderer/model/CommandRegistry.js index 6b239f5f..4a9e0661 100644 --- a/src/renderer/model/CommandRegistry.js +++ b/src/renderer/model/CommandRegistry.js @@ -7,6 +7,7 @@ import creationCommand from './commands/CreationCommands' import measureCommands from './commands/MeasureCommands' import shapeCommands from './commands/ShapeCommands' import elevationProfileCommands from './commands/ElevationProfileCommands' +import lineOfSightCommands from './commands/LineOfSightCommands' import printCommands from './commands/PrintCommands' import replicationCommands from './commands/ReplicationCommands' @@ -23,6 +24,7 @@ export function CommandRegistry (services) { Object.assign(this, measureCommands(services)) Object.assign(this, shapeCommands(services)) Object.assign(this, elevationProfileCommands(services)) + Object.assign(this, lineOfSightCommands(services)) Object.assign(this, printCommands(services)) Object.assign(this, replicationCommands(services)) diff --git a/src/renderer/model/commands/LineOfSightCommands.js b/src/renderer/model/commands/LineOfSightCommands.js new file mode 100644 index 00000000..c4ca3d1d --- /dev/null +++ b/src/renderer/model/commands/LineOfSightCommands.js @@ -0,0 +1,64 @@ +import EventEmitter from '../../../shared/emitter' +import * as ID from '../../ids' + +const hasTerrainService = async (store) => { + const tuples = await store.tuples(ID.TILE_SERVICE_SCOPE) + return tuples.some(([, service]) => + service?.capabilities?.contentType === 'terrain/mapbox-rgb' || + service?.terrain?.length > 0 + ) +} + +const hasWebGL2 = () => { + try { + const canvas = document.createElement('canvas') + return !!canvas.getContext('webgl2') + } catch { + return false + } +} + +const LineOfSight = function (services) { + this.emitter = services.emitter + this.store = services.store + this.label = 'Line of Sight' + this.path = 'mdiEye' + this.isEnabled = false + + // WebGL2 is a hard requirement (shared with planned Area-of-Sight). + // Without it, the command stays disabled regardless of terrain availability. + this.webgl2_ = hasWebGL2() + if (!this.webgl2_) return + + hasTerrainService(this.store).then(available => { + this.isEnabled = available + this.emit('changed') + }) + + this.store.on('batch', ({ operations }) => { + const relevant = operations.some(({ key }) => + ID.isTileServiceId(key) || ID.isTilePresetId(key) + ) + if (!relevant) return + hasTerrainService(this.store).then(available => { + if (this.isEnabled !== available) { + this.isEnabled = available + this.emit('changed') + } + }) + }) +} + +Object.assign(LineOfSight.prototype, EventEmitter.prototype) + +LineOfSight.prototype.execute = function () { + this.emitter.emit('LINE_OF_SIGHT') +} + +LineOfSight.prototype.enabled = function () { + return this.isEnabled +} + +export default services => ({ + LINE_OF_SIGHT: new LineOfSight(services) +}) diff --git a/src/renderer/ol/interaction/line-of-sight/compute.js b/src/renderer/ol/interaction/line-of-sight/compute.js new file mode 100644 index 00000000..be03d4f9 --- /dev/null +++ b/src/renderer/ol/interaction/line-of-sight/compute.js @@ -0,0 +1,95 @@ +import { getLength } from 'ol/sphere' +import LineString from 'ol/geom/LineString' + +const EARTH_RADIUS_M = 6371008.8 +const REFRACTION_K = 0.13 +const EFFECTIVE_RADIUS = EARTH_RADIUS_M / (1 - REFRACTION_K) + +export const MAX_DISTANCE_M = 10000 +export const DEFAULT_DISTANCE_M = 4000 +export const DEFAULT_OBSERVER_HEIGHT_M = 1.7 +export const DEFAULT_TARGET_HEIGHT_M = 1.7 + +const curvatureDrop = d => (d * d) / (2 * EFFECTIVE_RADIUS) + +/** + * Clamp the target coordinate to MAX_DISTANCE_M along the observer→target line. + * @returns {{coordinate:number[], distance:number, clipped:boolean}} + */ +export const clampToMaxDistance = (observer, target) => { + const line = new LineString([observer, target]) + const distance = getLength(line) + if (distance <= MAX_DISTANCE_M) { + return { coordinate: target, distance, clipped: false } + } + const fraction = MAX_DISTANCE_M / distance + const coordinate = line.getCoordinateAt(fraction) + return { coordinate, distance: MAX_DISTANCE_M, clipped: true } +} + +/** + * Compute Line-of-Sight from observer to target with earth-curvature + * and atmospheric-refraction correction. + * + * @returns {Promise, + * observerGroundElev:number, targetGroundElev:number, + * observerEyeElev:number, targetEyeElev:number, + * firstBlocker: null | {index:number, distance:number, coordinate:number[], elevation:number}, + * visible:boolean, + * clipped:boolean + * }>} + */ +export const computeLineOfSight = async ({ + observer, target, observerHeight, targetHeight, elevationService, zoom +}) => { + const { coordinate: clampedTarget, distance, clipped } = clampToMaxDistance(observer, target) + if (distance < 1) return null + + const tileGrid = elevationService.tileGrid_ + if (!tileGrid) return null + + const maxZ = tileGrid.getMaxZoom() + const minZ = tileGrid.getMinZoom() + const z = Math.max(minZ, Math.min(maxZ, Math.round(zoom))) + const tileResolutionMeters = tileGrid.getResolution(z) + const step = Math.max(5, tileResolutionMeters) + const numSamples = Math.min(800, Math.max(20, Math.ceil(distance / step))) + + const geometry = new LineString([observer, clampedTarget]) + const samples = await elevationService.profileAlongLine(geometry, numSamples, zoom) + if (samples.length < 2) return null + if (samples[0].elevation == null || samples[samples.length - 1].elevation == null) return null + + const observerGroundElev = samples[0].elevation + const targetGroundElev = samples[samples.length - 1].elevation + const observerEyeElev = observerGroundElev + observerHeight + const targetEyeElev = targetGroundElev + targetHeight + + const targetCorrected = targetEyeElev - curvatureDrop(distance) + const losAt = d => observerEyeElev + (targetCorrected - observerEyeElev) * (d / distance) + + let firstBlocker = null + for (let i = 1; i < samples.length - 1; i++) { + const s = samples[i] + if (s.elevation == null) continue + const corrected = s.elevation - curvatureDrop(s.distance) + if (corrected > losAt(s.distance)) { + firstBlocker = { index: i, distance: s.distance, coordinate: s.coordinate, elevation: s.elevation } + break + } + } + + return { + distance, + samples, + observerGroundElev, + targetGroundElev, + observerEyeElev, + targetEyeElev, + firstBlocker, + visible: firstBlocker == null, + clipped + } +} diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js new file mode 100644 index 00000000..c5359bf6 --- /dev/null +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -0,0 +1,238 @@ +import Feature from 'ol/Feature' +import Point from 'ol/geom/Point' +import LineString from 'ol/geom/LineString' +import { Vector as VectorSource } from 'ol/source' +import { Vector as VectorLayer } from 'ol/layer' +import { unByKey } from 'ol/Observable' +import uuid from '../../../../shared/uuid' +import { ElevationService } from '../../../model/ElevationService' +import { + computeLineOfSight, + DEFAULT_OBSERVER_HEIGHT_M, + DEFAULT_TARGET_HEIGHT_M +} from './compute' +import { + visibleSegmentStyle, + blockedSegmentStyle, + observerPointStyle, + blockerPointStyle, + clipMarkerStyle +} from './style' + +const ORIGINATOR_ID = uuid() + +export default ({ map, services }) => { + const elevationService = new ElevationService() + + const source = new VectorSource() + const vector = new VectorLayer({ source, style: null }) + map.addLayer(vector) + + /** @type {'idle' | 'placing-observer' | 'tracking-target'} */ + let mode = 'idle' + let observer = null + const observerHeight = DEFAULT_OBSERVER_HEIGHT_M + const targetHeight = DEFAULT_TARGET_HEIGHT_M + let computeGeneration = 0 + let clickKey = null + let moveKey = null + + const setCursor = (value) => { + const viewport = map.getViewport() + if (viewport) viewport.style.cursor = value + } + + let visibleSegmentFeature = null + let blockedSegmentFeature = null + let observerFeature = null + let blockerFeature = null + let clipMarkerFeature = null + + const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) + + /** + * Remove only the in-progress features (those held by the current slot). + * Previously finalised LoS features stay on the map. + */ + const clearOverlay = () => { + if (visibleSegmentFeature) source.removeFeature(visibleSegmentFeature) + if (blockedSegmentFeature) source.removeFeature(blockedSegmentFeature) + if (observerFeature) source.removeFeature(observerFeature) + if (blockerFeature) source.removeFeature(blockerFeature) + if (clipMarkerFeature) source.removeFeature(clipMarkerFeature) + visibleSegmentFeature = null + blockedSegmentFeature = null + observerFeature = null + blockerFeature = null + clipMarkerFeature = null + } + + /** + * Detach the current feature handles without removing features from the source. + * The features then become a frozen, persistent LoS on the map. + */ + const detachCurrentFeatures = () => { + visibleSegmentFeature = null + blockedSegmentFeature = null + observerFeature = null + blockerFeature = null + clipMarkerFeature = null + } + + const detachMapListeners = () => { + if (clickKey) { unByKey(clickKey); clickKey = null } + if (moveKey) { unByKey(moveKey); moveKey = null } + } + + const reset = () => { + detachMapListeners() + clearOverlay() + observer = null + mode = 'idle' + setCursor('') + // Invalidate any in-flight compute so its result will not be rendered. + computeGeneration++ + showOSD('') + } + + const setObserverFeature = (coord) => { + if (!observerFeature) { + observerFeature = new Feature(new Point(coord)) + observerFeature.setStyle(observerPointStyle) + source.addFeature(observerFeature) + } else { + observerFeature.getGeometry().setCoordinates(coord) + } + } + + const removeFeatureIfPresent = (feature) => { + if (feature) source.removeFeature(feature) + } + + const renderResult = (result) => { + if (!result) { + removeFeatureIfPresent(visibleSegmentFeature); visibleSegmentFeature = null + removeFeatureIfPresent(blockedSegmentFeature); blockedSegmentFeature = null + removeFeatureIfPresent(blockerFeature); blockerFeature = null + removeFeatureIfPresent(clipMarkerFeature); clipMarkerFeature = null + return + } + + const { samples, firstBlocker, clipped } = result + const lastCoord = samples[samples.length - 1].coordinate + + const visibleEndIdx = firstBlocker ? firstBlocker.index : samples.length - 1 + const visibleCoords = samples.slice(0, visibleEndIdx + 1).map(s => s.coordinate) + if (!visibleSegmentFeature) { + visibleSegmentFeature = new Feature(new LineString(visibleCoords)) + visibleSegmentFeature.setStyle(visibleSegmentStyle) + source.addFeature(visibleSegmentFeature) + } else { + visibleSegmentFeature.getGeometry().setCoordinates(visibleCoords) + } + + if (firstBlocker) { + const blockedCoords = samples.slice(firstBlocker.index).map(s => s.coordinate) + if (!blockedSegmentFeature) { + blockedSegmentFeature = new Feature(new LineString(blockedCoords)) + blockedSegmentFeature.setStyle(blockedSegmentStyle) + source.addFeature(blockedSegmentFeature) + } else { + blockedSegmentFeature.getGeometry().setCoordinates(blockedCoords) + } + if (!blockerFeature) { + blockerFeature = new Feature(new Point(firstBlocker.coordinate)) + blockerFeature.setStyle(blockerPointStyle) + source.addFeature(blockerFeature) + } else { + blockerFeature.getGeometry().setCoordinates(firstBlocker.coordinate) + } + } else { + removeFeatureIfPresent(blockedSegmentFeature); blockedSegmentFeature = null + removeFeatureIfPresent(blockerFeature); blockerFeature = null + } + + if (clipped) { + if (!clipMarkerFeature) { + clipMarkerFeature = new Feature(new Point(lastCoord)) + clipMarkerFeature.setStyle(clipMarkerStyle) + source.addFeature(clipMarkerFeature) + } else { + clipMarkerFeature.getGeometry().setCoordinates(lastCoord) + } + } else { + removeFeatureIfPresent(clipMarkerFeature); clipMarkerFeature = null + } + + const dKm = (result.distance / 1000).toFixed(2) + const dEye = Math.round(result.targetEyeElev - result.observerEyeElev) + const blockerInfo = firstBlocker + ? ` | blocked at ${(firstBlocker.distance / 1000).toFixed(2)} km` + : ' | clear' + const clipInfo = result.clipped ? ' (max 10 km)' : '' + showOSD(`LoS: ${dKm} km${clipInfo} | Δh ${dEye} m${blockerInfo}`) + } + + const recompute = async (target) => { + const gen = ++computeGeneration + const result = await computeLineOfSight({ + observer, + target, + observerHeight, + targetHeight, + elevationService, + zoom: map.getView().getZoom() + }) + if (gen !== computeGeneration) return + renderResult(result) + } + + const onPointerMove = (event) => { + if (mode !== 'tracking-target' || !observer) return + if (event.dragging) return + recompute(event.coordinate) + } + + const onSingleClick = async (event) => { + if (mode === 'placing-observer') { + observer = event.coordinate + setObserverFeature(observer) + mode = 'tracking-target' + showOSD('LoS: move cursor to choose target, click to fix') + } else if (mode === 'tracking-target') { + const coordinate = event.coordinate + mode = 'idle' + setCursor('') + detachMapListeners() + // Finalise: run one last compute with the click coordinate, then + // release the current feature handles so the result stays on the map. + await recompute(coordinate) + detachCurrentFeatures() + observer = null + showOSD('') + } + } + + const start = () => { + reset() + if (!elevationService.setSource(map)) { + showOSD('No terrain layer available') + setTimeout(() => showOSD(''), 3000) + return + } + mode = 'placing-observer' + setCursor('crosshair') + showOSD('LoS: click to place observer') + clickKey = map.on('singleclick', onSingleClick) + moveKey = map.on('pointermove', onPointerMove) + } + + services.emitter.on('LINE_OF_SIGHT', () => { + services.emitter.emit('command/draw/cancel', { originatorId: ORIGINATOR_ID }) + start() + }) + + services.emitter.on('command/draw/cancel', ({ originatorId }) => { + if (originatorId !== ORIGINATOR_ID) reset() + }) +} diff --git a/src/renderer/ol/interaction/line-of-sight/style.js b/src/renderer/ol/interaction/line-of-sight/style.js new file mode 100644 index 00000000..c8bc0b17 --- /dev/null +++ b/src/renderer/ol/interaction/line-of-sight/style.js @@ -0,0 +1,44 @@ +import { Stroke, Style, Fill, Circle as CircleStyle, RegularShape } from 'ol/style' + +export const visibleSegmentStyle = new Style({ + stroke: new Stroke({ color: 'rgba(0, 180, 60, 0.95)', width: 4 }), + zIndex: 1 +}) + +export const blockedSegmentStyle = new Style({ + stroke: new Stroke({ + color: 'rgba(220, 30, 30, 0.95)', + width: 4, + lineDash: [8, 6] + }), + zIndex: 1 +}) + +export const observerPointStyle = new Style({ + image: new CircleStyle({ + radius: 7, + fill: new Fill({ color: 'rgba(0, 120, 220, 0.95)' }), + stroke: new Stroke({ color: '#fff', width: 2 }) + }), + zIndex: 10 +}) + +export const blockerPointStyle = new Style({ + image: new RegularShape({ + points: 4, + radius: 9, + angle: Math.PI / 4, + fill: new Fill({ color: 'rgba(220, 30, 30, 0.95)' }), + stroke: new Stroke({ color: '#fff', width: 2 }) + }), + zIndex: 5 +}) + +export const clipMarkerStyle = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: 'rgba(220, 140, 0, 0.9)' }), + stroke: new Stroke({ color: '#fff', width: 2 }) + }), + zIndex: 5 +}) From e5de4dc997394c382aa110b42cc64ebb5df9d4bb Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Tue, 26 May 2026 11:40:06 +0200 Subject: [PATCH 02/10] feat(los): persist Line-of-Sight features across reloads Adds a LOS scope (los:{uuid}) to the id catalogue and rewires the line-of-sight module to be store-driven. Finalised LoS results are written to the store, restored on app start, and rebuilt incrementally on subsequent put/del operations. The in-progress (live-preview) overlay is unchanged. On finalisation the live features are handed over directly to the persistent map so there is no flicker, and a fresh losId is inserted into the store. A small race-safe initial-load path waits for a terrain layer to be available before rendering the persisted set. Heights are still fixed to the 1.70 m defaults; the per-feature height editor will land alongside selection support in a separate change. --- src/renderer/ids.js | 4 + .../ol/interaction/line-of-sight/index.js | 232 +++++++++++++----- 2 files changed, 181 insertions(+), 55 deletions(-) diff --git a/src/renderer/ids.js b/src/renderer/ids.js index 815d4135..3d0202bb 100644 --- a/src/renderer/ids.js +++ b/src/renderer/ids.js @@ -29,6 +29,7 @@ export const DEFAULT = 'default' export const TAGS = 'tags' export const STICKY = 'sticky' export const MEASURE = 'measure' +export const LOS = 'los' export const SHARED = 'shared' export const INVITED = 'invited' @@ -45,6 +46,7 @@ export const TILE_PRESET_SCOPE = TILE_PRESET + COLON export const TILE_LAYER_SCOPE = TILE_LAYER + COLON export const SSE_SERVICE_SCOPE = SSE_SERVICE + COLON export const MEASURE_SCOPE = MEASURE + COLON +export const LOS_SCOPE = LOS + COLON export const LINK_PREFIX = 'link' + PLUS export const STYLE_PREFIX = 'style' + PLUS @@ -106,6 +108,7 @@ export const isHiddenId = isId(HIDDEN_PREFIX) export const isDefaultId = isId(DEFAULT_PREFIX) export const isTagsId = isId(TAGS_PREFIX) export const isMeasureId = isId(MEASURE_SCOPE) +export const isLosId = isId(LOS_SCOPE) export const isSharedLayerId = isId(sharedId(LAYER_SCOPE)) export const isInvitedId = isId(INVITED) export const isRoleId = isId(ROLE_PREFIX) @@ -173,6 +176,7 @@ export const tileLayerId = (tileServiceId, layerId) => export const markerId = () => makeId(MARKER, uuid()) export const bookmarkId = () => makeId(BOOKMARK, uuid()) export const measureId = () => makeId(MEASURE, uuid()) +export const losId = () => makeId(LOS, uuid()) export const linkId = id => LINK + PLUS + id + SLASH + uuid() export const invitationId = () => makeId(INVITED, uuid()) diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index c5359bf6..424ea3e9 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -5,6 +5,7 @@ import { Vector as VectorSource } from 'ol/source' import { Vector as VectorLayer } from 'ol/layer' import { unByKey } from 'ol/Observable' import uuid from '../../../../shared/uuid' +import * as ID from '../../../ids' import { ElevationService } from '../../../model/ElevationService' import { computeLineOfSight, @@ -20,6 +21,7 @@ import { } from './style' const ORIGINATOR_ID = uuid() +const LOS_DOC_TYPE = 'los' export default ({ map, services }) => { const elevationService = new ElevationService() @@ -28,6 +30,17 @@ export default ({ map, services }) => { const vector = new VectorLayer({ source, style: null }) map.addLayer(vector) + // Features per persisted LoS, keyed by losId. + // Each entry: { observer, visible, blocked?, blocker?, clip? } + const featuresByLosId = new Map() + + // In-progress (live-preview) features. Hand-over to featuresByLosId on finalise. + let visibleSegmentFeature = null + let blockedSegmentFeature = null + let observerFeature = null + let blockerFeature = null + let clipMarkerFeature = null + /** @type {'idle' | 'placing-observer' | 'tracking-target'} */ let mode = 'idle' let observer = null @@ -42,57 +55,22 @@ export default ({ map, services }) => { if (viewport) viewport.style.cursor = value } - let visibleSegmentFeature = null - let blockedSegmentFeature = null - let observerFeature = null - let blockerFeature = null - let clipMarkerFeature = null - const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) - /** - * Remove only the in-progress features (those held by the current slot). - * Previously finalised LoS features stay on the map. - */ - const clearOverlay = () => { - if (visibleSegmentFeature) source.removeFeature(visibleSegmentFeature) - if (blockedSegmentFeature) source.removeFeature(blockedSegmentFeature) - if (observerFeature) source.removeFeature(observerFeature) - if (blockerFeature) source.removeFeature(blockerFeature) - if (clipMarkerFeature) source.removeFeature(clipMarkerFeature) - visibleSegmentFeature = null - blockedSegmentFeature = null - observerFeature = null - blockerFeature = null - clipMarkerFeature = null - } - - /** - * Detach the current feature handles without removing features from the source. - * The features then become a frozen, persistent LoS on the map. - */ - const detachCurrentFeatures = () => { - visibleSegmentFeature = null - blockedSegmentFeature = null - observerFeature = null - blockerFeature = null - clipMarkerFeature = null - } + // ──────────────────────────────────────────────────────────── + // In-progress overlay (live preview during placement) + // ──────────────────────────────────────────────────────────── - const detachMapListeners = () => { - if (clickKey) { unByKey(clickKey); clickKey = null } - if (moveKey) { unByKey(moveKey); moveKey = null } + const removeFeatureIfPresent = (feature) => { + if (feature) source.removeFeature(feature) } - const reset = () => { - detachMapListeners() - clearOverlay() - observer = null - mode = 'idle' - setCursor('') - // Invalidate any in-flight compute so its result will not be rendered. - computeGeneration++ - showOSD('') + const clearInProgressOverlay = () => { + removeFeatureIfPresent(visibleSegmentFeature); visibleSegmentFeature = null + removeFeatureIfPresent(blockedSegmentFeature); blockedSegmentFeature = null + removeFeatureIfPresent(observerFeature); observerFeature = null + removeFeatureIfPresent(blockerFeature); blockerFeature = null + removeFeatureIfPresent(clipMarkerFeature); clipMarkerFeature = null } const setObserverFeature = (coord) => { @@ -105,10 +83,6 @@ export default ({ map, services }) => { } } - const removeFeatureIfPresent = (feature) => { - if (feature) source.removeFeature(feature) - } - const renderResult = (result) => { if (!result) { removeFeatureIfPresent(visibleSegmentFeature); visibleSegmentFeature = null @@ -183,8 +157,159 @@ export default ({ map, services }) => { elevationService, zoom: map.getView().getZoom() }) - if (gen !== computeGeneration) return + if (gen !== computeGeneration) return null renderResult(result) + return result + } + + // ──────────────────────────────────────────────────────────── + // Persisted LoS rendering (store-driven) + // ──────────────────────────────────────────────────────────── + + const buildFeaturesFromResult = (result) => { + const { samples, firstBlocker, clipped } = result + const observerCoord = samples[0].coordinate + const lastCoord = samples[samples.length - 1].coordinate + + const visibleEndIdx = firstBlocker ? firstBlocker.index : samples.length - 1 + const visibleCoords = samples.slice(0, visibleEndIdx + 1).map(s => s.coordinate) + + const entry = {} + + entry.observer = new Feature(new Point(observerCoord)) + entry.observer.setStyle(observerPointStyle) + source.addFeature(entry.observer) + + entry.visible = new Feature(new LineString(visibleCoords)) + entry.visible.setStyle(visibleSegmentStyle) + source.addFeature(entry.visible) + + if (firstBlocker) { + const blockedCoords = samples.slice(firstBlocker.index).map(s => s.coordinate) + entry.blocked = new Feature(new LineString(blockedCoords)) + entry.blocked.setStyle(blockedSegmentStyle) + source.addFeature(entry.blocked) + + entry.blocker = new Feature(new Point(firstBlocker.coordinate)) + entry.blocker.setStyle(blockerPointStyle) + source.addFeature(entry.blocker) + } + + if (clipped) { + entry.clip = new Feature(new Point(lastCoord)) + entry.clip.setStyle(clipMarkerStyle) + source.addFeature(entry.clip) + } + + return entry + } + + const renderPersistedLos = async (losId, doc) => { + if (featuresByLosId.has(losId)) return + if (!elevationService.setSource(map)) return + if (!doc || !doc.observer || !doc.target) return + + const result = await computeLineOfSight({ + observer: doc.observer, + target: doc.target, + observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M, + elevationService, + zoom: map.getView().getZoom() + }) + if (!result) return + // Re-check after await — could have been added by a concurrent path. + if (featuresByLosId.has(losId)) return + + const entry = buildFeaturesFromResult(result) + featuresByLosId.set(losId, entry) + } + + const removePersistedLos = (losId) => { + const entry = featuresByLosId.get(losId) + if (!entry) return + Object.values(entry).forEach(f => source.removeFeature(f)) + featuresByLosId.delete(losId) + } + + const tryInitialLoad = async () => { + if (!elevationService.setSource(map)) return false + const tuples = await services.store.tuples(ID.LOS_SCOPE) + for (const [id, doc] of tuples) { + if (!featuresByLosId.has(id)) renderPersistedLos(id, doc) + } + return true + } + + ;(async () => { + if (await tryInitialLoad()) return + // Terrain not available yet — retry once a layer is added. + const key = map.getLayers().on('add', async () => { + if (await tryInitialLoad()) unByKey(key) + }) + })() + + services.store.on('batch', ({ operations }) => { + for (const op of operations) { + if (!ID.isLosId(op.key)) continue + if (op.type === 'put') renderPersistedLos(op.key, op.value) + else if (op.type === 'del') removePersistedLos(op.key) + } + }) + + // ──────────────────────────────────────────────────────────── + // Tool lifecycle + // ──────────────────────────────────────────────────────────── + + const detachMapListeners = () => { + if (clickKey) { unByKey(clickKey); clickKey = null } + if (moveKey) { unByKey(moveKey); moveKey = null } + } + + const reset = () => { + detachMapListeners() + clearInProgressOverlay() + observer = null + mode = 'idle' + setCursor('') + // Invalidate any in-flight compute so its result will not be rendered. + computeGeneration++ + showOSD('') + } + + const finalise = async (coordinate) => { + const result = await recompute(coordinate) + if (!result || !observerFeature || !visibleSegmentFeature) { + clearInProgressOverlay() + return + } + + // Hand the in-progress features over as a persistent entry. + const losId = ID.losId() + featuresByLosId.set(losId, { + observer: observerFeature, + visible: visibleSegmentFeature, + blocked: blockedSegmentFeature, + blocker: blockerFeature, + clip: clipMarkerFeature + }) + visibleSegmentFeature = null + blockedSegmentFeature = null + observerFeature = null + blockerFeature = null + clipMarkerFeature = null + + // Persist using the clamped target (so re-render after reload is identical). + const samples = result.samples + const persistedTarget = samples[samples.length - 1].coordinate + const doc = { + type: LOS_DOC_TYPE, + observer: result.samples[0].coordinate, + target: persistedTarget, + observerHeight, + targetHeight + } + services.store.insert([[losId, doc]]) } const onPointerMove = (event) => { @@ -204,10 +329,7 @@ export default ({ map, services }) => { mode = 'idle' setCursor('') detachMapListeners() - // Finalise: run one last compute with the click coordinate, then - // release the current feature handles so the result stays on the map. - await recompute(coordinate) - detachCurrentFeatures() + await finalise(coordinate) observer = null showOSD('') } From 0d0fb11b1e7cd6a2f4d964cd2adb18911dcb8a61 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Tue, 26 May 2026 11:49:43 +0200 Subject: [PATCH 03/10] feat(los): selection + properties panel with height editor Selecting a finalised LoS on the map (click) now opens a Properties panel with editable observer/target height fields and a read-only distance display. Edits flow through store.update, our batch handler detects the change and rebuilds the visual representation; the existing clipboard-delete pipeline removes a selected LoS without any extra wiring. All sub-features of a LoS (observer point, visible/blocked segments, blocker diamond, clip marker) carry the same losId as OpenLayers feature id, so a click on any of them selects the whole document. The LoS layer is tagged selectable; the map-wide Select interaction is suspended while the placement tool is live so the first/second click never doubles as a feature select. Multi-select shows M/V in the height fields and accepts a value to apply to all selected LoS at once, mirroring how the other property panels behave. --- .../properties/LineOfSightProperties.js | 57 ++++++++ .../components/properties/Properties.js | 4 +- .../ol/interaction/line-of-sight/index.js | 125 ++++++++++++------ 3 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 src/renderer/components/properties/LineOfSightProperties.js diff --git a/src/renderer/components/properties/LineOfSightProperties.js b/src/renderer/components/properties/LineOfSightProperties.js new file mode 100644 index 00000000..fec02860 --- /dev/null +++ b/src/renderer/components/properties/LineOfSightProperties.js @@ -0,0 +1,57 @@ +/* eslint-disable react/prop-types */ +import React from 'react' +import { getLength } from 'ol/sphere' +import LineString from 'ol/geom/LineString' +import textProperty from './textProperty' +import GridCols2 from './GridCols2' +import ColSpan2 from './ColSpan2' + +const formatHeight = h => (typeof h === 'number' ? h.toFixed(2) : '') + +const setHeight = key => value => feature => { + const num = parseFloat(value) + if (!Number.isFinite(num) || num < 0) return feature + return { ...feature, [key]: num } +} + +const ObserverHeight = textProperty({ + label: 'Observer height [m]', + get: feature => formatHeight(feature.observerHeight), + set: setHeight('observerHeight') +}) + +const TargetHeight = textProperty({ + label: 'Target height [m]', + get: feature => formatHeight(feature.targetHeight), + set: setHeight('targetHeight') +}) + +const distanceKm = (doc) => { + if (!doc?.observer || !doc?.target) return null + return getLength(new LineString([doc.observer, doc.target])) / 1000 +} + +const LineOfSightProperties = (props) => { + const docs = Object.values(props.features) + const single = docs.length === 1 + const km = single ? distanceKm(docs[0]) : null + + return ( + + + + {single && km !== null && ( + +
+ Distance + + {km.toFixed(2)} km + +
+
+ )} +
+ ) +} + +export default LineOfSightProperties diff --git a/src/renderer/components/properties/Properties.js b/src/renderer/components/properties/Properties.js index 57eecdbe..f6338f94 100644 --- a/src/renderer/components/properties/Properties.js +++ b/src/renderer/components/properties/Properties.js @@ -20,6 +20,7 @@ import SKKMUnitProperties from './SKKMUnitProperties' import SKKMCommandProperties from './SKKMCommandProperties' import ShapeProperties from './ShapeProperties' import TextShapeProperties from './TextShapeProperties' +import LineOfSightProperties from './LineOfSightProperties' import './Properties.css' const propertiesPanels = { @@ -38,7 +39,8 @@ const propertiesPanels = { 'sse-service': props => , 'feature:SKKM/K': props => , 'feature:SKKM/KU': props => , - 'feature:SKKM/KC': props => + 'feature:SKKM/KC': props => , + los: props => } const singletons = ['tile-service', 'tile-layers', 'sse-service'] diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index 424ea3e9..bbefcc4e 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -3,6 +3,7 @@ import Point from 'ol/geom/Point' import LineString from 'ol/geom/LineString' import { Vector as VectorSource } from 'ol/source' import { Vector as VectorLayer } from 'ol/layer' +import { Select } from 'ol/interaction' import { unByKey } from 'ol/Observable' import uuid from '../../../../shared/uuid' import * as ID from '../../../ids' @@ -28,10 +29,13 @@ export default ({ map, services }) => { const source = new VectorSource() const vector = new VectorLayer({ source, style: null }) + vector.set('selectable', true) map.addLayer(vector) - // Features per persisted LoS, keyed by losId. - // Each entry: { observer, visible, blocked?, blocker?, clip? } + // Per persisted LoS, keyed by losId. Each entry: + // { doc, features: { observer, visible, blocked?, blocker?, clip? } } + // `doc` is the last value used to render so we can detect actual changes + // (heights, observer, target) on store batch put operations. const featuresByLosId = new Map() // In-progress (live-preview) features. Hand-over to featuresByLosId on finalise. @@ -166,7 +170,7 @@ export default ({ map, services }) => { // Persisted LoS rendering (store-driven) // ──────────────────────────────────────────────────────────── - const buildFeaturesFromResult = (result) => { + const buildFeaturesFromResult = (losId, result) => { const { samples, firstBlocker, clipped } = result const observerCoord = samples[0].coordinate const lastCoord = samples[samples.length - 1].coordinate @@ -174,38 +178,51 @@ export default ({ map, services }) => { const visibleEndIdx = firstBlocker ? firstBlocker.index : samples.length - 1 const visibleCoords = samples.slice(0, visibleEndIdx + 1).map(s => s.coordinate) - const entry = {} + const features = {} - entry.observer = new Feature(new Point(observerCoord)) - entry.observer.setStyle(observerPointStyle) - source.addFeature(entry.observer) + features.observer = new Feature(new Point(observerCoord)) + features.observer.setStyle(observerPointStyle) + features.observer.setId(losId) + source.addFeature(features.observer) - entry.visible = new Feature(new LineString(visibleCoords)) - entry.visible.setStyle(visibleSegmentStyle) - source.addFeature(entry.visible) + features.visible = new Feature(new LineString(visibleCoords)) + features.visible.setStyle(visibleSegmentStyle) + features.visible.setId(losId) + source.addFeature(features.visible) if (firstBlocker) { const blockedCoords = samples.slice(firstBlocker.index).map(s => s.coordinate) - entry.blocked = new Feature(new LineString(blockedCoords)) - entry.blocked.setStyle(blockedSegmentStyle) - source.addFeature(entry.blocked) - - entry.blocker = new Feature(new Point(firstBlocker.coordinate)) - entry.blocker.setStyle(blockerPointStyle) - source.addFeature(entry.blocker) + features.blocked = new Feature(new LineString(blockedCoords)) + features.blocked.setStyle(blockedSegmentStyle) + features.blocked.setId(losId) + source.addFeature(features.blocked) + + features.blocker = new Feature(new Point(firstBlocker.coordinate)) + features.blocker.setStyle(blockerPointStyle) + features.blocker.setId(losId) + source.addFeature(features.blocker) } if (clipped) { - entry.clip = new Feature(new Point(lastCoord)) - entry.clip.setStyle(clipMarkerStyle) - source.addFeature(entry.clip) + features.clip = new Feature(new Point(lastCoord)) + features.clip.setStyle(clipMarkerStyle) + features.clip.setId(losId) + source.addFeature(features.clip) } - return entry + return features } + const sameCoord = (a, b) => a && b && a[0] === b[0] && a[1] === b[1] + + const docChanged = (a, b) => + !a || !b || + a.observerHeight !== b.observerHeight || + a.targetHeight !== b.targetHeight || + !sameCoord(a.observer, b.observer) || + !sameCoord(a.target, b.target) + const renderPersistedLos = async (losId, doc) => { - if (featuresByLosId.has(losId)) return if (!elevationService.setSource(map)) return if (!doc || !doc.observer || !doc.target) return @@ -218,17 +235,18 @@ export default ({ map, services }) => { zoom: map.getView().getZoom() }) if (!result) return - // Re-check after await — could have been added by a concurrent path. - if (featuresByLosId.has(losId)) return + // A concurrent path may have rendered it while we awaited; if so, + // drop the existing features so we win and stay consistent with `doc`. + if (featuresByLosId.has(losId)) removePersistedLos(losId) - const entry = buildFeaturesFromResult(result) - featuresByLosId.set(losId, entry) + const features = buildFeaturesFromResult(losId, result) + featuresByLosId.set(losId, { doc, features }) } const removePersistedLos = (losId) => { const entry = featuresByLosId.get(losId) if (!entry) return - Object.values(entry).forEach(f => source.removeFeature(f)) + Object.values(entry.features).forEach(f => f && source.removeFeature(f)) featuresByLosId.delete(losId) } @@ -236,7 +254,8 @@ export default ({ map, services }) => { if (!elevationService.setSource(map)) return false const tuples = await services.store.tuples(ID.LOS_SCOPE) for (const [id, doc] of tuples) { - if (!featuresByLosId.has(id)) renderPersistedLos(id, doc) + const existing = featuresByLosId.get(id) + if (!existing || docChanged(existing.doc, doc)) renderPersistedLos(id, doc) } return true } @@ -252,8 +271,15 @@ export default ({ map, services }) => { services.store.on('batch', ({ operations }) => { for (const op of operations) { if (!ID.isLosId(op.key)) continue - if (op.type === 'put') renderPersistedLos(op.key, op.value) - else if (op.type === 'del') removePersistedLos(op.key) + if (op.type === 'del') { + removePersistedLos(op.key) + continue + } + // put: skip when the stored doc matches what we already rendered + // (e.g. our own self-echo right after finalise). + const existing = featuresByLosId.get(op.key) + if (existing && !docChanged(existing.doc, op.value)) continue + renderPersistedLos(op.key, op.value) } }) @@ -266,12 +292,24 @@ export default ({ map, services }) => { if (moveKey) { unByKey(moveKey); moveKey = null } } + // The map's Select interaction reacts to the same singleclick events we + // use for placing the observer/target. Deactivate it while the LoS tool + // is live so a click does not also select the LoS that was just drawn. + const selectInteraction = () => + map.getInteractions().getArray().find(i => i instanceof Select) + + const setSelectActive = (active) => { + const select = selectInteraction() + if (select) select.setActive(active) + } + const reset = () => { detachMapListeners() clearInProgressOverlay() observer = null mode = 'idle' setCursor('') + setSelectActive(true) // Invalidate any in-flight compute so its result will not be rendered. computeGeneration++ showOSD('') @@ -284,24 +322,21 @@ export default ({ map, services }) => { return } - // Hand the in-progress features over as a persistent entry. + // Hand the in-progress features over as a persistent entry; assigning + // the losId lets the select-interaction map clicks on any sub-feature + // back to the same document. const losId = ID.losId() - featuresByLosId.set(losId, { + const features = { observer: observerFeature, visible: visibleSegmentFeature, blocked: blockedSegmentFeature, blocker: blockerFeature, clip: clipMarkerFeature - }) - visibleSegmentFeature = null - blockedSegmentFeature = null - observerFeature = null - blockerFeature = null - clipMarkerFeature = null + } + Object.values(features).forEach(f => f && f.setId(losId)) // Persist using the clamped target (so re-render after reload is identical). - const samples = result.samples - const persistedTarget = samples[samples.length - 1].coordinate + const persistedTarget = result.samples[result.samples.length - 1].coordinate const doc = { type: LOS_DOC_TYPE, observer: result.samples[0].coordinate, @@ -309,6 +344,14 @@ export default ({ map, services }) => { observerHeight, targetHeight } + featuresByLosId.set(losId, { doc, features }) + + visibleSegmentFeature = null + blockedSegmentFeature = null + observerFeature = null + blockerFeature = null + clipMarkerFeature = null + services.store.insert([[losId, doc]]) } @@ -329,6 +372,7 @@ export default ({ map, services }) => { mode = 'idle' setCursor('') detachMapListeners() + setSelectActive(true) await finalise(coordinate) observer = null showOSD('') @@ -344,6 +388,7 @@ export default ({ map, services }) => { } mode = 'placing-observer' setCursor('crosshair') + setSelectActive(false) showOSD('LoS: click to place observer') clickKey = map.on('singleclick', onSingleClick) moveKey = map.on('pointermove', onPointerMove) From 913cca1d333f6e0203971147cb330a7184fe26f3 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 16:34:38 +0200 Subject: [PATCH 04/10] feat(elevation): deterministic terrain sampling at fixed analysis zoom - ElevationService samples at a data-driven analysis zoom (finest zoom resolving <= 15 m/cell) instead of the current view zoom: LoS and elevation profile results no longer change with zoom or after reload - promise-based tile cache deduplicates concurrent downloads; tiles are decoded once into Float32 elevations - profileAlongLine fetches required tiles in parallel, then samples synchronously - getGrid(extent) stitches a tile-aligned Float32 elevation grid (foundation for Area-of-Sight), coarsening zoom to fit a cell budget - drop the WebGL2 gate from the LoS command (LoS is CPU sampling only) --- src/renderer/model/ElevationService.js | 260 ++++++++++++------ .../model/commands/LineOfSightCommands.js | 14 - .../ol/interaction/elevation-profile/index.js | 15 +- .../ol/interaction/line-of-sight/compute.js | 16 +- .../ol/interaction/line-of-sight/index.js | 6 +- test/renderer/model/ElevationService-test.js | 111 ++++++++ 6 files changed, 301 insertions(+), 121 deletions(-) create mode 100644 test/renderer/model/ElevationService-test.js diff --git a/src/renderer/model/ElevationService.js b/src/renderer/model/ElevationService.js index ed03b919..ad253fae 100644 --- a/src/renderer/model/ElevationService.js +++ b/src/renderer/model/ElevationService.js @@ -1,24 +1,44 @@ import { getLength } from 'ol/sphere' -const MAX_CACHE_SIZE = 200 +const MAX_CACHE_SIZE = 200 // decoded tiles à 256 KB → ≤ 50 MB +const TILE_SIZE = 256 -const elevation = rgb => { - if (!rgb) return null - const value = -10000 + (((rgb[0] << 16) + (rgb[1] << 8) + rgb[2]) * 0.1) - if (value === -10000) return null - return value +// Elevation analysis samples at a fixed, data-driven zoom so results are +// deterministic — independent of the current view zoom. Finest zoom whose +// resolution is at or above this value is used (Mapbox Terrain-RGB z14 +// ≈ 9.6 m at the equator). +const TARGET_ANALYSIS_RESOLUTION = 15 + +// Upper bound for stitched grids (getGrid); ~80 MB Float32. The zoom is +// coarsened until the requested extent fits. +const MAX_GRID_CELLS = 20e6 + +/** + * Decode one Mapbox Terrain-RGB tile into elevations [m]. + * No-data pixels become NaN. + */ +const decodeTile = imageData => { + const rgba = imageData.data + const elevations = new Float32Array(TILE_SIZE * TILE_SIZE) + for (let i = 0; i < elevations.length; i++) { + const o = i * 4 + const value = -10000 + (((rgba[o] << 16) + (rgba[o + 1] << 8) + rgba[o + 2]) * 0.1) + elevations[i] = value === -10000 ? NaN : value + } + return elevations } /** * Viewport-independent elevation sampling from terrain tiles. - * Fetches XYZ tile images directly and decodes RGB-encoded elevation. - * Reusable by future viewshed feature. + * Fetches XYZ tile images directly and decodes RGB-encoded elevation + * into Float32 arrays. Shared foundation for elevation profile, + * Line-of-Sight and Area-of-Sight (viewshed). */ export function ElevationService () { this.source_ = null this.tileGrid_ = null this.tileUrlFunction_ = null - this.tileCache_ = new Map() + this.tileCache_ = new Map() // 'z/x/y' -> Promise } /** @@ -34,6 +54,7 @@ ElevationService.prototype.setSource = function (map) { const layer = terrainLayers[0] const source = layer.getSource() + if (source !== this.source_) this.tileCache_.clear() this.source_ = source this.tileGrid_ = source.getTileGrid() this.tileUrlFunction_ = source.getTileUrlFunction() @@ -41,119 +62,190 @@ ElevationService.prototype.setSource = function (map) { } /** - * Build tile URL from tile coordinate using the source's URL function. - * Works for all source types (XYZ, TileJSON, etc.). - * @param {number} z - * @param {number} x - * @param {number} y - * @returns {string|undefined} + * Fixed zoom used for all analysis sampling: the coarsest zoom that still + * resolves TARGET_ANALYSIS_RESOLUTION, clamped to the tile grid's range. + * @returns {number|null} */ -ElevationService.prototype.tileUrl_ = function (z, x, y) { - return this.tileUrlFunction_([z, x, y], 1, this.source_.getProjection()) +ElevationService.prototype.analysisZoom = function () { + if (!this.tileGrid_) return null + const minZ = this.tileGrid_.getMinZoom() + const maxZ = this.tileGrid_.getMaxZoom() + for (let z = minZ; z <= maxZ; z++) { + if (this.tileGrid_.getResolution(z) <= TARGET_ANALYSIS_RESOLUTION) return z + } + return maxZ } /** - * Fetch a tile and return its ImageData (cached). - * @param {string} key - cache key "z/x/y" - * @param {string} url - tile URL - * @returns {Promise} + * Cell size [projection units ≈ m] at the analysis zoom. + * @returns {number|null} */ -ElevationService.prototype.fetchTile_ = async function (key, url) { - if (this.tileCache_.has(key)) return this.tileCache_.get(key) +ElevationService.prototype.analysisResolution = function () { + const z = this.analysisZoom() + return z === null ? null : this.tileGrid_.getResolution(z) +} - try { - const imageData = await new Promise((resolve, reject) => { +/** + * Fetch and decode a tile (promise-cached, so concurrent requests for + * the same tile share one download). + * @returns {Promise} + */ +ElevationService.prototype.fetchTile_ = function (z, x, y) { + const key = `${z}/${x}/${y}` + if (this.tileCache_.has(key)) { + const hit = this.tileCache_.get(key) + this.tileCache_.delete(key) // LRU: re-insert as most recent + this.tileCache_.set(key, hit) + return hit + } + + const url = this.tileUrlFunction_([z, x, y], 1, this.source_.getProjection()) + const promise = !url + ? Promise.resolve(null) + : new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => { - const canvas = new OffscreenCanvas(256, 256) + const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE) const ctx = canvas.getContext('2d', { willReadFrequently: true }) ctx.drawImage(img, 0, 0) - resolve(ctx.getImageData(0, 0, 256, 256)) + resolve(decodeTile(ctx.getImageData(0, 0, TILE_SIZE, TILE_SIZE))) } img.onerror = () => reject(new Error(`Failed to load tile: ${url}`)) img.src = url + }).catch(() => { + this.tileCache_.delete(key) // do not cache failures + return null }) - // LRU eviction: remove oldest entry if cache is full - if (this.tileCache_.size >= MAX_CACHE_SIZE) { - const firstKey = this.tileCache_.keys().next().value - this.tileCache_.delete(firstKey) - } - this.tileCache_.set(key, imageData) - return imageData - } catch { - return null + if (this.tileCache_.size >= MAX_CACHE_SIZE) { + const oldest = this.tileCache_.keys().next().value + this.tileCache_.delete(oldest) } + this.tileCache_.set(key, promise) + return promise } /** - * Get elevation at a single coordinate. - * @param {import('ol/coordinate').Coordinate} coordinate - in map projection - * @param {number} zoom - * @returns {Promise} + * Sample a decoded tile at a coordinate. + * @returns {number|null} */ -ElevationService.prototype.elevationAt = async function (coordinate, zoom) { - if (!this.source_) return null - - const maxZ = this.tileGrid_.getMaxZoom() - const minZ = this.tileGrid_.getMinZoom() - const z = Math.max(minZ, Math.min(maxZ, Math.round(zoom))) - const tileCoord = this.tileGrid_.getTileCoordForCoordAndZ(coordinate, z) - const [tz, tx, ty] = tileCoord - const key = `${tz}/${tx}/${ty}` - const url = this.tileUrl_(tz, tx, ty) - if (!url) return null - - const imageData = await this.fetchTile_(key, url) - if (!imageData) return null - - // Calculate pixel offset within tile - const tileOrigin = this.tileGrid_.getTileCoordExtent(tileCoord) - const tileSize = 256 - const resolution = (tileOrigin[2] - tileOrigin[0]) / tileSize - - const px = Math.floor((coordinate[0] - tileOrigin[0]) / resolution) - const py = Math.floor((tileOrigin[3] - coordinate[1]) / resolution) - - // Clamp to tile bounds - const cx = Math.max(0, Math.min(tileSize - 1, px)) - const cy = Math.max(0, Math.min(tileSize - 1, py)) - - const offset = (cy * tileSize + cx) * 4 - const rgb = [imageData.data[offset], imageData.data[offset + 1], imageData.data[offset + 2]] - return elevation(rgb) +ElevationService.prototype.sample_ = function (elevations, tileCoord, coordinate) { + const extent = this.tileGrid_.getTileCoordExtent(tileCoord) + const resolution = (extent[2] - extent[0]) / TILE_SIZE + const px = Math.max(0, Math.min(TILE_SIZE - 1, Math.floor((coordinate[0] - extent[0]) / resolution))) + const py = Math.max(0, Math.min(TILE_SIZE - 1, Math.floor((extent[3] - coordinate[1]) / resolution))) + const value = elevations[py * TILE_SIZE + px] + return Number.isNaN(value) ? null : value } /** - * Get elevations at multiple coordinates (batch, for future viewshed). - * @param {Array} coordinates - * @param {number} zoom - * @returns {Promise>} + * Get elevation at a single coordinate (at the analysis zoom). + * @param {import('ol/coordinate').Coordinate} coordinate - in map projection + * @returns {Promise} */ -ElevationService.prototype.elevationsAt = async function (coordinates, zoom) { - return Promise.all(coordinates.map(c => this.elevationAt(c, zoom))) +ElevationService.prototype.elevationAt = async function (coordinate) { + if (!this.source_) return null + const z = this.analysisZoom() + const tileCoord = this.tileGrid_.getTileCoordForCoordAndZ(coordinate, z) + const elevations = await this.fetchTile_(...tileCoord) + return elevations ? this.sample_(elevations, tileCoord, coordinate) : null } /** - * Sample elevation profile along a LineString geometry. + * Sample elevation profile along a LineString geometry. Required tiles + * are fetched in parallel, then all samples are read synchronously. * @param {import('ol/geom/LineString').default} lineStringGeom - in map projection * @param {number} numSamples - * @param {number} zoom * @returns {Promise>} */ -ElevationService.prototype.profileAlongLine = async function (lineStringGeom, numSamples, zoom) { +ElevationService.prototype.profileAlongLine = async function (lineStringGeom, numSamples) { + if (!this.source_) return [] const totalLength = getLength(lineStringGeom) if (totalLength === 0 || numSamples < 2) return [] - const results = [] + const z = this.analysisZoom() + const samples = [] for (let i = 0; i < numSamples; i++) { const fraction = i / (numSamples - 1) const coordinate = lineStringGeom.getCoordinateAt(fraction) - const distance = totalLength * fraction - const elev = await this.elevationAt(coordinate, zoom) - results.push({ distance, elevation: elev, coordinate }) + const tileCoord = this.tileGrid_.getTileCoordForCoordAndZ(coordinate, z) + samples.push({ distance: totalLength * fraction, coordinate, tileCoord }) } - return results + const tiles = new Map() // 'z/x/y' -> Float32Array|null + const unique = [...new Map(samples.map(s => [s.tileCoord.join('/'), s.tileCoord])).values()] + await Promise.all(unique.map(async tileCoord => { + tiles.set(tileCoord.join('/'), await this.fetchTile_(...tileCoord)) + })) + + return samples.map(({ distance, coordinate, tileCoord }) => { + const elevations = tiles.get(tileCoord.join('/')) + const elevation = elevations ? this.sample_(elevations, tileCoord, coordinate) : null + return { distance, elevation, coordinate } + }) +} + +/** + * Stitch a tile-aligned elevation grid covering the extent (for viewshed). + * The zoom starts at the analysis zoom and is coarsened until the grid + * fits MAX_GRID_CELLS. Missing tiles yield NaN cells. + * + * @param {import('ol/extent').Extent} extent - in map projection + * @returns {Promise} + */ +ElevationService.prototype.getGrid = async function (extent) { + if (!this.source_) return null + + let z = this.analysisZoom() + const minZ = this.tileGrid_.getMinZoom() + const rangeFor = z => this.tileGrid_.getTileRangeForExtentAndZ(extent, z) + let range = rangeFor(z) + const cells = r => (r.getWidth() * TILE_SIZE) * (r.getHeight() * TILE_SIZE) + while (cells(range) > MAX_GRID_CELLS && z > minZ) { + z -= 1 + range = rangeFor(z) + } + if (cells(range) > MAX_GRID_CELLS) return null + + const cols = range.getWidth() + const rows = range.getHeight() + const width = cols * TILE_SIZE + const height = rows * TILE_SIZE + const data = new Float32Array(width * height).fill(NaN) + + const jobs = [] + for (let ty = range.minY; ty <= range.maxY; ty++) { + for (let tx = range.minX; tx <= range.maxX; tx++) { + jobs.push( + this.fetchTile_(z, tx, ty).then(elevations => { + if (!elevations) return + const dx = (tx - range.minX) * TILE_SIZE + const dy = (ty - range.minY) * TILE_SIZE + for (let row = 0; row < TILE_SIZE; row++) { + data.set( + elevations.subarray(row * TILE_SIZE, (row + 1) * TILE_SIZE), + (dy + row) * width + dx + ) + } + }) + ) + } + } + await Promise.all(jobs) + + const topLeft = this.tileGrid_.getTileCoordExtent([z, range.minX, range.minY]) + return { + data, + width, + height, + origin: [topLeft[0], topLeft[3]], + resolution: this.tileGrid_.getResolution(z), + zoom: z + } } diff --git a/src/renderer/model/commands/LineOfSightCommands.js b/src/renderer/model/commands/LineOfSightCommands.js index c4ca3d1d..fb62e734 100644 --- a/src/renderer/model/commands/LineOfSightCommands.js +++ b/src/renderer/model/commands/LineOfSightCommands.js @@ -9,15 +9,6 @@ const hasTerrainService = async (store) => { ) } -const hasWebGL2 = () => { - try { - const canvas = document.createElement('canvas') - return !!canvas.getContext('webgl2') - } catch { - return false - } -} - const LineOfSight = function (services) { this.emitter = services.emitter this.store = services.store @@ -25,11 +16,6 @@ const LineOfSight = function (services) { this.path = 'mdiEye' this.isEnabled = false - // WebGL2 is a hard requirement (shared with planned Area-of-Sight). - // Without it, the command stays disabled regardless of terrain availability. - this.webgl2_ = hasWebGL2() - if (!this.webgl2_) return - hasTerrainService(this.store).then(available => { this.isEnabled = available this.emit('changed') diff --git a/src/renderer/ol/interaction/elevation-profile/index.js b/src/renderer/ol/interaction/elevation-profile/index.js index 164c85c5..ea397e17 100644 --- a/src/renderer/ol/interaction/elevation-profile/index.js +++ b/src/renderer/ol/interaction/elevation-profile/index.js @@ -166,22 +166,17 @@ export default ({ map, services }) => { return } - const zoom = map.getView().getZoom() const lineLength = getLength(geometry) if (lineLength === 0) return - // Determine resolution-appropriate sample count - // Clamp to tile grid's zoom range — terrain tiles have a maxZoom - const tileGrid = elevationService.tileGrid_ - const maxZ = tileGrid.getMaxZoom() - const minZ = tileGrid.getMinZoom() - const z = Math.max(minZ, Math.min(maxZ, Math.round(zoom))) - const tilePixelResolution = tileGrid.getResolution(z) - const numSamples = Math.min(600, Math.max(20, Math.round(lineLength / tilePixelResolution))) + // Sample count from the service's fixed analysis resolution — the + // profile must not change with the current view zoom. + const resolution = elevationService.analysisResolution() + const numSamples = Math.min(600, Math.max(20, Math.round(lineLength / resolution))) showProfileLine(geometry) - const profile = await elevationService.profileAlongLine(geometry, numSamples, zoom) + const profile = await elevationService.profileAlongLine(geometry, numSamples) // Discard result if a newer computation has started if (generation !== profileGeneration) return diff --git a/src/renderer/ol/interaction/line-of-sight/compute.js b/src/renderer/ol/interaction/line-of-sight/compute.js index be03d4f9..30e59af4 100644 --- a/src/renderer/ol/interaction/line-of-sight/compute.js +++ b/src/renderer/ol/interaction/line-of-sight/compute.js @@ -42,23 +42,21 @@ export const clampToMaxDistance = (observer, target) => { * }>} */ export const computeLineOfSight = async ({ - observer, target, observerHeight, targetHeight, elevationService, zoom + observer, target, observerHeight, targetHeight, elevationService }) => { const { coordinate: clampedTarget, distance, clipped } = clampToMaxDistance(observer, target) if (distance < 1) return null - const tileGrid = elevationService.tileGrid_ - if (!tileGrid) return null + // Sampling is tied to the service's fixed analysis resolution, never to + // the view zoom — the same LoS must yield the same result at any zoom. + const resolution = elevationService.analysisResolution() + if (resolution === null) return null - const maxZ = tileGrid.getMaxZoom() - const minZ = tileGrid.getMinZoom() - const z = Math.max(minZ, Math.min(maxZ, Math.round(zoom))) - const tileResolutionMeters = tileGrid.getResolution(z) - const step = Math.max(5, tileResolutionMeters) + const step = Math.max(5, resolution) const numSamples = Math.min(800, Math.max(20, Math.ceil(distance / step))) const geometry = new LineString([observer, clampedTarget]) - const samples = await elevationService.profileAlongLine(geometry, numSamples, zoom) + const samples = await elevationService.profileAlongLine(geometry, numSamples) if (samples.length < 2) return null if (samples[0].elevation == null || samples[samples.length - 1].elevation == null) return null diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index bbefcc4e..216728cc 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -158,8 +158,7 @@ export default ({ map, services }) => { target, observerHeight, targetHeight, - elevationService, - zoom: map.getView().getZoom() + elevationService }) if (gen !== computeGeneration) return null renderResult(result) @@ -231,8 +230,7 @@ export default ({ map, services }) => { target: doc.target, observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M, - elevationService, - zoom: map.getView().getZoom() + elevationService }) if (!result) return // A concurrent path may have rendered it while we awaited; if so, diff --git a/test/renderer/model/ElevationService-test.js b/test/renderer/model/ElevationService-test.js new file mode 100644 index 00000000..51a3d49b --- /dev/null +++ b/test/renderer/model/ElevationService-test.js @@ -0,0 +1,111 @@ +import assert from 'assert' +import LineString from 'ol/geom/LineString' +import TileGrid from 'ol/tilegrid/TileGrid' +import { ElevationService } from '../../../src/renderer/model/ElevationService' + +// Web-Mercator-like tile grid: z0 = one 256px world tile. +const WORLD = 40075016.68557849 +const EXTENT = [-WORLD / 2, -WORLD / 2, WORLD / 2, WORLD / 2] +const resolutions = Array.from({ length: 16 }, (_, z) => WORLD / 256 / Math.pow(2, z)) + +const makeTileGrid = () => new TileGrid({ + extent: EXTENT, + origin: [-WORLD / 2, WORLD / 2], + resolutions, + tileSize: 256 +}) + +// Service with stubbed tile fetch: every tile is a flat plane whose +// elevation encodes its tile coordinate (x * 1e3 + y — small enough to +// stay exactly representable in Float32). +const makeService = (fetched = []) => { + const service = new ElevationService() + service.source_ = {} + service.tileGrid_ = makeTileGrid() + service.fetchTile_ = async (z, x, y) => { + fetched.push(`${z}/${x}/${y}`) + return new Float32Array(256 * 256).fill(x * 1e3 + y) + } + return service +} + +describe('ElevationService', function () { + describe('analysisZoom', function () { + it('picks the coarsest zoom at or below the target resolution', function () { + const service = makeService() + const z = service.analysisZoom() + // resolutions: z13 ≈ 19.1 m > 15 m, z14 ≈ 9.55 m ≤ 15 m + assert.strictEqual(z, 14) + }) + + it('falls back to maxZoom when no zoom reaches the target', function () { + const service = makeService() + service.tileGrid_ = new TileGrid({ + extent: EXTENT, + origin: [-WORLD / 2, WORLD / 2], + resolutions: resolutions.slice(0, 11), // max z10 ≈ 152 m + tileSize: 256 + }) + assert.strictEqual(service.analysisZoom(), 10) + }) + }) + + describe('profileAlongLine', function () { + it('samples at the analysis zoom and fetches each tile once', async function () { + const fetched = [] + const service = makeService(fetched) + const line = new LineString([[0, 0], [5000, 0]]) + + const profile = await service.profileAlongLine(line, 50) + + assert.strictEqual(profile.length, 50) + assert.strictEqual(profile[0].distance, 0) + assert.ok(profile.every(s => typeof s.elevation === 'number')) + assert.ok(fetched.every(key => key.startsWith('14/'))) + assert.strictEqual(new Set(fetched).size, fetched.length, 'no duplicate tile fetches') + }) + }) + + describe('getGrid', function () { + it('stitches tiles into one grid with top-left origin', async function () { + const service = makeService() + const resolution = resolutions[14] + // extent spanning 2×2 tiles at z14, top-right of world center + const tile = 256 * resolution + const extent = [100, 100, 100 + 1.5 * tile, 100 + 1.5 * tile] + + const grid = await service.getGrid(extent) + + assert.strictEqual(grid.zoom, 14) + assert.strictEqual(grid.width, 512) + assert.strictEqual(grid.height, 512) + assert.strictEqual(grid.resolution, resolution) + + // Cells carry their source tile's coordinate: rows 0..255 come from + // the northern tile row (smaller XYZ y), columns 0..255 from the + // western column. + const nw = grid.data[0] + const ne = grid.data[511] + const sw = grid.data[511 * 512] + assert.strictEqual(ne - nw, 1000, 'east neighbour tile has x+1') + assert.strictEqual(sw - nw, 1, 'south neighbour tile has y+1') + + // origin is the top-left (north-west) corner of the stitched extent + const [ox, oy] = grid.origin + assert.ok(ox <= 100) + assert.ok(oy >= 100 + 1.5 * tile) + }) + + it('coarsens the zoom when the extent exceeds the cell budget', async function () { + const service = makeService() + // 100 × 100 tiles at z14 → 655M cells → must drop several zoom levels + const tile = 256 * resolutions[14] + const extent = [0, 0, 100 * tile, 100 * tile] + + const grid = await service.getGrid(extent) + + assert.ok(grid.zoom < 14) + assert.ok(grid.width * grid.height <= 20e6) + }) + }) +}) From c12e4cac321ae843a26a28b9c2577f3e982ad2f4 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 16:34:53 +0200 Subject: [PATCH 05/10] feat(aos): real-time Area-of-Sight via WebGPU viewshed - R2 viewshed engine: WebGPU compute shader (one thread per ray), 10 km @ 10 m in single-digit ms; viewshedCPU as reference and fallback when no GPU adapter is available - tool follows the cursor with a live preview (latest-wins), click places the observer and persists the document in the aos: scope - visibility mask rendered as circular raster overlay (ImageCanvas), green/red, no-data cells stay transparent and never block - selectable observer point with properties panel for radius (default 2500 m, max 10 km) and observer/target heights; changes recompute - map clicks no longer deselect LoS/AoS results --- src/renderer/components/Toolbar.js | 3 +- src/renderer/components/map/Map.js | 2 + src/renderer/components/map/eventHandlers.js | 4 +- .../properties/AreaOfSightProperties.js | 48 +++ .../components/properties/Properties.js | 4 +- src/renderer/ids.js | 4 + src/renderer/model/CommandRegistry.js | 2 + .../model/commands/AreaOfSightCommands.js | 50 +++ .../ol/interaction/area-of-sight/engine.js | 294 +++++++++++++ .../ol/interaction/area-of-sight/index.js | 385 ++++++++++++++++++ test/renderer/viewshed-test.js | 77 ++++ 11 files changed, 869 insertions(+), 4 deletions(-) create mode 100644 src/renderer/components/properties/AreaOfSightProperties.js create mode 100644 src/renderer/model/commands/AreaOfSightCommands.js create mode 100644 src/renderer/ol/interaction/area-of-sight/engine.js create mode 100644 src/renderer/ol/interaction/area-of-sight/index.js create mode 100644 test/renderer/viewshed-test.js diff --git a/src/renderer/components/Toolbar.js b/src/renderer/components/Toolbar.js index 3cb8c2a7..8a119bb9 100644 --- a/src/renderer/components/Toolbar.js +++ b/src/renderer/components/Toolbar.js @@ -53,7 +53,8 @@ export const Toolbar = () => { commandRegistry.command('MEASURE_AREA'), commandRegistry.command('MEASURE_CIRCLE'), commandRegistry.command('ELEVATION_PROFILE'), - commandRegistry.command('LINE_OF_SIGHT') + commandRegistry.command('LINE_OF_SIGHT'), + commandRegistry.command('AREA_OF_SIGHT') ] const replicationCommands = [ diff --git a/src/renderer/components/map/Map.js b/src/renderer/components/map/Map.js index d499332e..beac4323 100644 --- a/src/renderer/components/map/Map.js +++ b/src/renderer/components/map/Map.js @@ -17,6 +17,7 @@ import measure from '../../ol/interaction/measure' import shapeInteraction from '../../ol/interaction/shape-interaction' import elevationProfile from '../../ol/interaction/elevation-profile' import lineOfSight from '../../ol/interaction/line-of-sight' +import areaOfSight from '../../ol/interaction/area-of-sight' import print from '../print' import './Map.css' import './ScaleLine.css' @@ -85,6 +86,7 @@ export const Map = () => { shapeInteraction({ services, map }) elevationProfile({ services, map }) lineOfSight({ services, map }) + areaOfSight({ services, map }) // Expose a function to query the current map resolution. services.getMapResolution = () => map.getView().getResolution() diff --git a/src/renderer/components/map/eventHandlers.js b/src/renderer/components/map/eventHandlers.js index 19029d68..6d39511c 100644 --- a/src/renderer/components/map/eventHandlers.js +++ b/src/renderer/components/map/eventHandlers.js @@ -95,9 +95,9 @@ const mapHandlers = (services, map) => { map.once('rendercomplete', ({ target }) => sendPreview(services, target)) map.on('pointermove', throttle(75, event => osdDriver.pointermove(event))) - // Deselect everything except features and markers. + // Deselect everything except features, markers and analysis results. map.on('click', () => { - const exclude = [ID.isFeatureId, ID.isMarkerId, ID.isMeasureId] + const exclude = [ID.isFeatureId, ID.isMarkerId, ID.isMeasureId, ID.isLosId, ID.isAosId] const deselect = selection.selected(x => !exclude.some(p => p(x))) if (deselect.length) selection.deselect(deselect) }) diff --git a/src/renderer/components/properties/AreaOfSightProperties.js b/src/renderer/components/properties/AreaOfSightProperties.js new file mode 100644 index 00000000..7d8d833a --- /dev/null +++ b/src/renderer/components/properties/AreaOfSightProperties.js @@ -0,0 +1,48 @@ +/* eslint-disable react/prop-types */ +import React from 'react' +import textProperty from './textProperty' +import GridCols2 from './GridCols2' +import ColSpan2 from './ColSpan2' +import { DEFAULT_RADIUS_M, MAX_RADIUS_M } from '../../ol/interaction/area-of-sight' + +const formatNumber = value => (typeof value === 'number' ? String(value) : '') + +const setHeight = key => value => feature => { + const num = parseFloat(value) + if (!Number.isFinite(num) || num < 0) return feature + return { ...feature, [key]: num } +} + +const Radius = textProperty({ + label: `Radius [m] (max ${MAX_RADIUS_M})`, + get: feature => formatNumber(feature.radius ?? DEFAULT_RADIUS_M), + set: value => feature => { + const num = parseFloat(value) + if (!Number.isFinite(num) || num < 100) return feature + return { ...feature, radius: Math.min(num, MAX_RADIUS_M) } + } +}) + +const ObserverHeight = textProperty({ + label: 'Observer height [m]', + get: feature => formatNumber(feature.observerHeight), + set: setHeight('observerHeight') +}) + +const TargetHeight = textProperty({ + label: 'Target height [m]', + get: feature => formatNumber(feature.targetHeight), + set: setHeight('targetHeight') +}) + +const AreaOfSightProperties = (props) => ( + + + + + + + +) + +export default AreaOfSightProperties diff --git a/src/renderer/components/properties/Properties.js b/src/renderer/components/properties/Properties.js index f6338f94..0ed5bddc 100644 --- a/src/renderer/components/properties/Properties.js +++ b/src/renderer/components/properties/Properties.js @@ -21,6 +21,7 @@ import SKKMCommandProperties from './SKKMCommandProperties' import ShapeProperties from './ShapeProperties' import TextShapeProperties from './TextShapeProperties' import LineOfSightProperties from './LineOfSightProperties' +import AreaOfSightProperties from './AreaOfSightProperties' import './Properties.css' const propertiesPanels = { @@ -40,7 +41,8 @@ const propertiesPanels = { 'feature:SKKM/K': props => , 'feature:SKKM/KU': props => , 'feature:SKKM/KC': props => , - los: props => + los: props => , + aos: props => } const singletons = ['tile-service', 'tile-layers', 'sse-service'] diff --git a/src/renderer/ids.js b/src/renderer/ids.js index 3d0202bb..86fd1d59 100644 --- a/src/renderer/ids.js +++ b/src/renderer/ids.js @@ -30,6 +30,7 @@ export const TAGS = 'tags' export const STICKY = 'sticky' export const MEASURE = 'measure' export const LOS = 'los' +export const AOS = 'aos' export const SHARED = 'shared' export const INVITED = 'invited' @@ -47,6 +48,7 @@ export const TILE_LAYER_SCOPE = TILE_LAYER + COLON export const SSE_SERVICE_SCOPE = SSE_SERVICE + COLON export const MEASURE_SCOPE = MEASURE + COLON export const LOS_SCOPE = LOS + COLON +export const AOS_SCOPE = AOS + COLON export const LINK_PREFIX = 'link' + PLUS export const STYLE_PREFIX = 'style' + PLUS @@ -109,6 +111,7 @@ export const isDefaultId = isId(DEFAULT_PREFIX) export const isTagsId = isId(TAGS_PREFIX) export const isMeasureId = isId(MEASURE_SCOPE) export const isLosId = isId(LOS_SCOPE) +export const isAosId = isId(AOS_SCOPE) export const isSharedLayerId = isId(sharedId(LAYER_SCOPE)) export const isInvitedId = isId(INVITED) export const isRoleId = isId(ROLE_PREFIX) @@ -177,6 +180,7 @@ export const markerId = () => makeId(MARKER, uuid()) export const bookmarkId = () => makeId(BOOKMARK, uuid()) export const measureId = () => makeId(MEASURE, uuid()) export const losId = () => makeId(LOS, uuid()) +export const aosId = () => makeId(AOS, uuid()) export const linkId = id => LINK + PLUS + id + SLASH + uuid() export const invitationId = () => makeId(INVITED, uuid()) diff --git a/src/renderer/model/CommandRegistry.js b/src/renderer/model/CommandRegistry.js index 4a9e0661..b2bc5080 100644 --- a/src/renderer/model/CommandRegistry.js +++ b/src/renderer/model/CommandRegistry.js @@ -8,6 +8,7 @@ import measureCommands from './commands/MeasureCommands' import shapeCommands from './commands/ShapeCommands' import elevationProfileCommands from './commands/ElevationProfileCommands' import lineOfSightCommands from './commands/LineOfSightCommands' +import areaOfSightCommands from './commands/AreaOfSightCommands' import printCommands from './commands/PrintCommands' import replicationCommands from './commands/ReplicationCommands' @@ -25,6 +26,7 @@ export function CommandRegistry (services) { Object.assign(this, shapeCommands(services)) Object.assign(this, elevationProfileCommands(services)) Object.assign(this, lineOfSightCommands(services)) + Object.assign(this, areaOfSightCommands(services)) Object.assign(this, printCommands(services)) Object.assign(this, replicationCommands(services)) diff --git a/src/renderer/model/commands/AreaOfSightCommands.js b/src/renderer/model/commands/AreaOfSightCommands.js new file mode 100644 index 00000000..cf36a5d3 --- /dev/null +++ b/src/renderer/model/commands/AreaOfSightCommands.js @@ -0,0 +1,50 @@ +import EventEmitter from '../../../shared/emitter' +import * as ID from '../../ids' + +const hasTerrainService = async (store) => { + const tuples = await store.tuples(ID.TILE_SERVICE_SCOPE) + return tuples.some(([, service]) => + service?.capabilities?.contentType === 'terrain/mapbox-rgb' || + service?.terrain?.length > 0 + ) +} + +const AreaOfSight = function (services) { + this.emitter = services.emitter + this.store = services.store + this.label = 'Area of Sight' + this.path = 'mdiEyeCircle' + this.isEnabled = false + + hasTerrainService(this.store).then(available => { + this.isEnabled = available + this.emit('changed') + }) + + this.store.on('batch', ({ operations }) => { + const relevant = operations.some(({ key }) => + ID.isTileServiceId(key) || ID.isTilePresetId(key) + ) + if (!relevant) return + hasTerrainService(this.store).then(available => { + if (this.isEnabled !== available) { + this.isEnabled = available + this.emit('changed') + } + }) + }) +} + +Object.assign(AreaOfSight.prototype, EventEmitter.prototype) + +AreaOfSight.prototype.execute = function () { + this.emitter.emit('AREA_OF_SIGHT') +} + +AreaOfSight.prototype.enabled = function () { + return this.isEnabled +} + +export default services => ({ + AREA_OF_SIGHT: new AreaOfSight(services) +}) diff --git a/src/renderer/ol/interaction/area-of-sight/engine.js b/src/renderer/ol/interaction/area-of-sight/engine.js new file mode 100644 index 00000000..c8232a04 --- /dev/null +++ b/src/renderer/ol/interaction/area-of-sight/engine.js @@ -0,0 +1,294 @@ +/* global GPUBufferUsage, GPUMapMode */ + +/** + * Viewshed (Area-of-Sight) engine. + * + * Algorithm: R2 — one ray per perimeter cell of the square ring around + * the observer, tracking the running maximum terrain slope along each + * ray (with earth-curvature/refraction correction). O(cells) total work. + * + * Primary backend is a WebGPU compute shader (one thread per ray, + * 10 km @ 10 m in single-digit milliseconds — see spikes/aos). The CPU + * implementation is the reference for tests and the fallback when no + * GPU adapter is available. + * + * Mask values: 0 unknown/outside, 1 visible, 2 hidden. + */ + +export const VISIBLE = 1 +export const HIDDEN = 2 + +// Grid cells with no elevation data carry this sentinel (NaN is not +// portable into WGSL — fast-math may optimize NaN comparisons away). +export const NO_DATA = -100000 + +const EARTH_RADIUS_M = 6371008.8 +const REFRACTION_K = 0.13 +export const CURVATURE = 1 / (2 * (EARTH_RADIUS_M / (1 - REFRACTION_K))) + +/** + * Clamped window of the viewshed square around the observer. + */ +export const maskWindow = (width, height, ox, oy, radius) => { + const x0 = Math.max(0, ox - radius) + const y0 = Math.max(0, oy - radius) + const x1 = Math.min(width - 1, ox + radius) + const y1 = Math.min(height - 1, oy + radius) + return { x0, y0, w: x1 - x0 + 1, h: y1 - y0 + 1 } +} + +/** + * CPU reference implementation. + * + * @param {{data: Float32Array, width: number, height: number}} grid + * @param {object} params - observer cell (ox, oy), radius [cells], + * metersPerCell (true ground size, Mercator-corrected), + * observerHeight/targetHeight [m] + * @returns {null | {mask: Uint8Array, x0, y0, w, h}} window-relative mask + */ +export const viewshedCPU = (grid, { ox, oy, radius, metersPerCell, observerHeight, targetHeight }) => { + const { data, width, height } = grid + if (ox < 0 || oy < 0 || ox >= width || oy >= height) return null + const ground = data[oy * width + ox] + if (ground <= NO_DATA) return null + + const window = maskWindow(width, height, ox, oy, radius) + const { x0, y0, w } = window + const mask = new Uint8Array(w * window.h) + const obsElev = ground + observerHeight + + const ray = (px, py) => { + const dist = Math.sqrt(px * px + py * py) + const steps = Math.round(dist) + if (steps === 0) return + const sx = px / steps + const sy = py / steps + const stepM = (dist / steps) * metersPerCell + let maxSlope = -Infinity + for (let i = 1; i <= steps; i++) { + const cx = (ox + sx * i) | 0 + const cy = (oy + sy * i) | 0 + if (cx < 0 || cy < 0 || cx >= width || cy >= height) return + const g = data[cy * width + cx] + if (g <= NO_DATA) continue // unknown cell: neither blocks nor shows + const dm = i * stepM + const corrected = g - dm * dm * CURVATURE + const slope = (corrected - obsElev) / dm + mask[(cy - y0) * w + (cx - x0)] = + slope + targetHeight / dm >= maxSlope ? VISIBLE : HIDDEN + if (slope > maxSlope) maxSlope = slope + } + } + + for (let k = -radius; k <= radius; k++) { + ray(k, -radius) + ray(k, radius) + ray(-radius, k) + ray(radius, k) + } + + return { mask, ...window } +} + +// ──────────────────────────────────────────────────────────── +// WebGPU backend +// ──────────────────────────────────────────────────────────── + +const WGSL = /* wgsl */ ` +struct Params { + width: u32, height: u32, ox: i32, oy: i32, + radius: i32, x0: i32, y0: i32, w: u32, + obsElev: f32, metersPerCell: f32, curvature: f32, targetHeight: f32 +} +@group(0) @binding(0) var dem : array; +@group(0) @binding(1) var mask : array; +@group(0) @binding(2) var P : Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid : vec3) { + let r = P.radius; + let k = i32(gid.x); + if (k >= 8 * r) { return; } + + // perimeter cell of the square ring, global index k in [0, 8r) + let side = k / (2 * r); + let t = (k % (2 * r)) - r; + var px = 0; var py = 0; + if (side == 0) { px = t; py = -r; } + else if (side == 1) { px = t; py = r; } + else if (side == 2) { px = -r; py = t; } + else { px = r; py = t; } + + let dist = sqrt(f32(px * px + py * py)); + let steps = i32(round(dist)); + if (steps == 0) { return; } + let sx = f32(px) / f32(steps); + let sy = f32(py) / f32(steps); + let stepM = dist / f32(steps) * P.metersPerCell; + let ox = f32(P.ox); + let oy = f32(P.oy); + var maxSlope = -1e30; + + for (var i = 1; i <= steps; i++) { + let cx = i32(ox + sx * f32(i)); + let cy = i32(oy + sy * f32(i)); + if (cx < 0 || cy < 0 || cx >= i32(P.width) || cy >= i32(P.height)) { return; } + let g = dem[u32(cy) * P.width + u32(cx)]; + if (g <= ${NO_DATA}.0) { continue; } + let dm = f32(i) * stepM; + let corrected = g - dm * dm * P.curvature; + let slope = (corrected - P.obsElev) / dm; + let idx = u32(cy - P.y0) * P.w + u32(cx - P.x0); + if (slope + P.targetHeight / dm >= maxSlope) { mask[idx] = 1u; } else { mask[idx] = 2u; } + if (slope > maxSlope) { maxSlope = slope; } + } +}` + +function WebGPUBackend (device) { + this.device_ = device + this.pipeline_ = device.createComputePipeline({ + layout: 'auto', + compute: { module: device.createShaderModule({ code: WGSL }), entryPoint: 'main' } + }) + this.uniformBuffer_ = device.createBuffer({ + size: 48, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST + }) + this.uniformData_ = new ArrayBuffer(48) + this.grid_ = null + this.demBuffer_ = null + this.maskBuffer_ = null + this.stagingBuffer_ = null + this.maskSize_ = 0 +} + +WebGPUBackend.prototype.uploadGrid_ = function (grid) { + if (this.grid_ === grid) return + const device = this.device_ + if (this.demBuffer_) this.demBuffer_.destroy() + this.demBuffer_ = device.createBuffer({ + size: grid.data.byteLength, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST + }) + device.queue.writeBuffer(this.demBuffer_, 0, grid.data) + this.grid_ = grid + this.bindGroup_ = null +} + +WebGPUBackend.prototype.ensureMaskBuffers_ = function (cells) { + if (this.maskSize_ >= cells && this.maskBuffer_) return + const device = this.device_ + if (this.maskBuffer_) this.maskBuffer_.destroy() + if (this.stagingBuffer_) this.stagingBuffer_.destroy() + this.maskBuffer_ = device.createBuffer({ + size: cells * 4, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST + }) + this.stagingBuffer_ = device.createBuffer({ + size: cells * 4, + usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST + }) + this.maskSize_ = cells + this.bindGroup_ = null +} + +WebGPUBackend.prototype.compute = async function (grid, params) { + const { ox, oy, radius, metersPerCell, observerHeight, targetHeight } = params + const { data, width, height } = grid + if (ox < 0 || oy < 0 || ox >= width || oy >= height) return null + const ground = data[oy * width + ox] + if (ground <= NO_DATA) return null + + const window = maskWindow(width, height, ox, oy, radius) + const cells = window.w * window.h + + this.uploadGrid_(grid) + this.ensureMaskBuffers_(cells) + if (!this.bindGroup_) { + this.bindGroup_ = this.device_.createBindGroup({ + layout: this.pipeline_.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: { buffer: this.demBuffer_ } }, + { binding: 1, resource: { buffer: this.maskBuffer_ } }, + { binding: 2, resource: { buffer: this.uniformBuffer_ } } + ] + }) + } + + const u32 = new Uint32Array(this.uniformData_, 0, 8) + const i32 = new Int32Array(this.uniformData_, 0, 8) + const f32 = new Float32Array(this.uniformData_, 32, 4) + u32[0] = width; u32[1] = height + i32[2] = ox; i32[3] = oy + i32[4] = radius; i32[5] = window.x0; i32[6] = window.y0 + u32[7] = window.w + f32[0] = ground + observerHeight + f32[1] = metersPerCell + f32[2] = CURVATURE + f32[3] = targetHeight + this.device_.queue.writeBuffer(this.uniformBuffer_, 0, this.uniformData_) + + const encoder = this.device_.createCommandEncoder() + encoder.clearBuffer(this.maskBuffer_, 0, cells * 4) + const pass = encoder.beginComputePass() + pass.setPipeline(this.pipeline_) + pass.setBindGroup(0, this.bindGroup_) + pass.dispatchWorkgroups(Math.ceil(8 * radius / 64)) + pass.end() + encoder.copyBufferToBuffer(this.maskBuffer_, 0, this.stagingBuffer_, 0, cells * 4) + this.device_.queue.submit([encoder.finish()]) + + await this.stagingBuffer_.mapAsync(GPUMapMode.READ, 0, cells * 4) + const mask = new Uint32Array(this.stagingBuffer_.getMappedRange(0, cells * 4).slice(0)) + this.stagingBuffer_.unmap() + + return { mask, ...window } +} + +// ──────────────────────────────────────────────────────────── +// Engine facade +// ──────────────────────────────────────────────────────────── + +export function ViewshedEngine () { + this.backend_ = null + this.ready_ = null + this.queue_ = Promise.resolve() +} + +/** + * @returns {Promise<'webgpu'|'cpu'>} resolved backend + */ +ViewshedEngine.prototype.init = function () { + if (this.ready_) return this.ready_ + this.ready_ = (async () => { + try { + const adapter = navigator.gpu && await navigator.gpu.requestAdapter() + if (adapter) { + const device = await adapter.requestDevice() + this.backend_ = new WebGPUBackend(device) + return 'webgpu' + } + } catch (err) { + console.warn('[ViewshedEngine] WebGPU unavailable, falling back to CPU:', err.message) + } + return 'cpu' + })() + return this.ready_ +} + +/** + * Compute a viewshed mask. See viewshedCPU for parameters. + * Calls are serialized: concurrent computes would otherwise destroy + * GPU buffers that still have queued work (grid re-upload). + * @returns {Promise} + */ +ViewshedEngine.prototype.compute = function (grid, params) { + const run = this.queue_.then(async () => { + await this.init() + return this.backend_ + ? this.backend_.compute(grid, params) + : viewshedCPU(grid, params) + }) + this.queue_ = run.catch(() => {}) // keep the chain alive after errors + return run +} diff --git a/src/renderer/ol/interaction/area-of-sight/index.js b/src/renderer/ol/interaction/area-of-sight/index.js new file mode 100644 index 00000000..cc036700 --- /dev/null +++ b/src/renderer/ol/interaction/area-of-sight/index.js @@ -0,0 +1,385 @@ +import Feature from 'ol/Feature' +import Point from 'ol/geom/Point' +import { Vector as VectorSource } from 'ol/source' +import { Vector as VectorLayer, Image as ImageLayer } from 'ol/layer' +import ImageCanvas from 'ol/source/ImageCanvas' +import { Select } from 'ol/interaction' +import { unByKey } from 'ol/Observable' +import { toLonLat } from 'ol/proj' +import { containsExtent } from 'ol/extent' +import uuid from '../../../../shared/uuid' +import * as ID from '../../../ids' +import { ElevationService } from '../../../model/ElevationService' +import { ViewshedEngine, VISIBLE, HIDDEN, NO_DATA } from './engine' +import { observerPointStyle } from '../line-of-sight/style' + +const ORIGINATOR_ID = uuid() +const AOS_DOC_TYPE = 'aos' + +export const DEFAULT_RADIUS_M = 2500 +export const MAX_RADIUS_M = 10000 +const DEFAULT_OBSERVER_HEIGHT_M = 2 +const DEFAULT_TARGET_HEIGHT_M = 2 + +const VISIBLE_RGBA = [40, 170, 60, 100] +const HIDDEN_RGBA = [200, 40, 40, 100] + +export default ({ map, services }) => { + const elevationService = new ElevationService() + const engine = new ViewshedEngine() + + // ──────────────────────────────────────────────────────────── + // Raster overlay: one ImageCanvas source composites the live + // preview and all persisted viewsheds into the current view. + // ──────────────────────────────────────────────────────────── + + // aosId -> { doc, canvas, extent, feature } + const entries = new Map() + let preview = null // { canvas, extent } + + const composite = document.createElement('canvas') + + const canvasFunction = (extent, resolution, pixelRatio, size) => { + composite.width = size[0] + composite.height = size[1] + const ctx = composite.getContext('2d') + const scale = pixelRatio / resolution + const draw = entry => { + if (!entry) return + const [minX, minY, maxX, maxY] = entry.extent + ctx.drawImage( + entry.canvas, + (minX - extent[0]) * scale, + (extent[3] - maxY) * scale, + (maxX - minX) * scale, + (maxY - minY) * scale + ) + } + entries.forEach(draw) + draw(preview) + return composite + } + + const rasterSource = new ImageCanvas({ canvasFunction, ratio: 1 }) + map.addLayer(new ImageLayer({ source: rasterSource })) + + const vectorSource = new VectorSource() + const vector = new VectorLayer({ source: vectorSource, style: null }) + vector.set('selectable', true) + map.addLayer(vector) + + /** + * Render a mask window into a colorized canvas, clipped to the + * circular radius around the observer. + */ + const colorize = (result, observerCell, radius) => { + const { mask, x0, y0, w, h } = result + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = h + const ctx = canvas.getContext('2d') + const image = ctx.createImageData(w, h) + const data = image.data + const cx = observerCell[0] - x0 + const cy = observerCell[1] - y0 + const r2 = radius * radius + for (let y = 0; y < h; y++) { + const dy = y - cy + for (let x = 0; x < w; x++) { + const dx = x - cx + if (dx * dx + dy * dy > r2) continue + const value = mask[y * w + x] + if (value !== VISIBLE && value !== HIDDEN) continue + const rgba = value === VISIBLE ? VISIBLE_RGBA : HIDDEN_RGBA + const o = (y * w + x) * 4 + data[o] = rgba[0]; data[o + 1] = rgba[1]; data[o + 2] = rgba[2]; data[o + 3] = rgba[3] + } + } + ctx.putImageData(image, 0, 0) + return canvas + } + + const windowExtent = (grid, { x0, y0, w, h }) => { + const [gx, gy] = grid.origin + const res = grid.resolution + return [gx + x0 * res, gy - (y0 + h) * res, gx + (x0 + w) * res, gy - y0 * res] + } + + // ──────────────────────────────────────────────────────────── + // Grid management + // ──────────────────────────────────────────────────────────── + + let grid = null + let gridExtent = null + + const sanitize = data => { + for (let i = 0; i < data.length; i++) if (Number.isNaN(data[i])) data[i] = NO_DATA + } + + // Radius in projection units: Web Mercator inflates ground distances + // by 1/cos(lat). + const projectedRadius = (coordinate, radiusM) => { + const lat = toLonLat(coordinate)[1] * Math.PI / 180 + return radiusM / Math.max(0.087, Math.cos(lat)) // clamp beyond ±85° + } + + const requiredExtent = (coordinate, radiusM) => { + const r = projectedRadius(coordinate, radiusM) * 1.05 + return [coordinate[0] - r, coordinate[1] - r, coordinate[0] + r, coordinate[1] + r] + } + + const ensureGrid = async (coordinate, radiusM) => { + const required = requiredExtent(coordinate, radiusM) + if (grid && containsExtent(gridExtent, required)) return grid + const fetched = await elevationService.getGrid(required) + if (!fetched) return null + sanitize(fetched.data) + grid = fetched + gridExtent = [ + grid.origin[0], + grid.origin[1] - grid.height * grid.resolution, + grid.origin[0] + grid.width * grid.resolution, + grid.origin[1] + ] + return grid + } + + /** + * Compute viewshed for observer coordinate; returns everything the + * render side needs, or null (no data at observer, no terrain, ...). + */ + const computeViewshed = async (coordinate, doc) => { + const radiusM = Math.min(doc.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) + const g = await ensureGrid(coordinate, radiusM) + if (!g) return null + + const res = g.resolution + const ox = Math.floor((coordinate[0] - g.origin[0]) / res) + const oy = Math.floor((g.origin[1] - coordinate[1]) / res) + const lat = toLonLat(coordinate)[1] * Math.PI / 180 + const metersPerCell = res * Math.max(0.087, Math.cos(lat)) + const radius = Math.max(2, Math.round(radiusM / metersPerCell)) + + const result = await engine.compute(g, { + ox, + oy, + radius, + metersPerCell, + observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M + }) + if (!result) return null + + return { + canvas: colorize(result, [ox, oy], radius), + extent: windowExtent(g, result) + } + } + + // ──────────────────────────────────────────────────────────── + // Persisted AoS rendering (store-driven) + // ──────────────────────────────────────────────────────────── + + const removePersistedAos = (aosId) => { + const entry = entries.get(aosId) + if (!entry) return + if (entry.feature) vectorSource.removeFeature(entry.feature) + entries.delete(aosId) + rasterSource.changed() + } + + const sameCoord = (a, b) => a && b && a[0] === b[0] && a[1] === b[1] + + const docChanged = (a, b) => + !a || !b || + a.radius !== b.radius || + a.observerHeight !== b.observerHeight || + a.targetHeight !== b.targetHeight || + !sameCoord(a.observer, b.observer) + + const renderPersistedAos = async (aosId, doc) => { + if (!elevationService.setSource(map)) return + if (!doc || !doc.observer) return + + const rendered = await computeViewshed(doc.observer, doc) + if (!rendered) return + if (entries.has(aosId)) removePersistedAos(aosId) + + const feature = new Feature(new Point(doc.observer)) + feature.setStyle(observerPointStyle) + feature.setId(aosId) + vectorSource.addFeature(feature) + + entries.set(aosId, { doc, ...rendered, feature }) + rasterSource.changed() + } + + const tryInitialLoad = async () => { + if (!elevationService.setSource(map)) return false + const tuples = await services.store.tuples(ID.AOS_SCOPE) + for (const [id, doc] of tuples) { + const existing = entries.get(id) + if (!existing || docChanged(existing.doc, doc)) renderPersistedAos(id, doc) + } + return true + } + + ;(async () => { + if (await tryInitialLoad()) return + const key = map.getLayers().on('add', async () => { + if (await tryInitialLoad()) unByKey(key) + }) + })() + + services.store.on('batch', ({ operations }) => { + for (const op of operations) { + if (!ID.isAosId(op.key)) continue + if (op.type === 'del') { + removePersistedAos(op.key) + continue + } + const existing = entries.get(op.key) + if (existing && !docChanged(existing.doc, op.value)) continue + renderPersistedAos(op.key, op.value) + } + }) + + // ──────────────────────────────────────────────────────────── + // Tool lifecycle: viewshed follows the cursor, click to fix + // ──────────────────────────────────────────────────────────── + + /** @type {'idle' | 'tracking'} */ + let mode = 'idle' + let clickKey = null + let moveKey = null + let busy = false + let pending = null + let generation = 0 + + const liveDoc = { + radius: DEFAULT_RADIUS_M, + observerHeight: DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: DEFAULT_TARGET_HEIGHT_M + } + + const setCursor = value => { + const viewport = map.getViewport() + if (viewport) viewport.style.cursor = value + } + + const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) + + const selectInteraction = () => + map.getInteractions().getArray().find(i => i instanceof Select) + + const setSelectActive = active => { + const select = selectInteraction() + if (select) select.setActive(active) + } + + const clearPreview = () => { + preview = null + rasterSource.changed() + } + + const showPreview = async (coordinate) => { + const gen = generation + const rendered = await computeViewshed(coordinate, liveDoc) + if (gen !== generation) return + preview = rendered + rasterSource.changed() + } + + const track = async (coordinate) => { + if (busy) { + pending = coordinate + return + } + busy = true + try { + await showPreview(coordinate) + while (pending) { + const next = pending + pending = null + await showPreview(next) + } + } finally { + busy = false + } + } + + const detachMapListeners = () => { + if (clickKey) { unByKey(clickKey); clickKey = null } + if (moveKey) { unByKey(moveKey); moveKey = null } + } + + const reset = () => { + detachMapListeners() + clearPreview() + mode = 'idle' + pending = null + generation++ + setCursor('') + setSelectActive(true) + showOSD('') + } + + const finalise = async (coordinate) => { + const doc = { + type: AOS_DOC_TYPE, + observer: coordinate, + radius: liveDoc.radius, + observerHeight: liveDoc.observerHeight, + targetHeight: liveDoc.targetHeight + } + // Render as persisted entry; store insert echoes back via batch but + // docChanged() will skip the redundant recompute. + const aosId = ID.aosId() + await renderPersistedAos(aosId, doc) + services.store.insert([[aosId, doc]]) + } + + const onPointerMove = (event) => { + if (mode !== 'tracking' || event.dragging) return + track(event.coordinate) + } + + const onSingleClick = async (event) => { + if (mode !== 'tracking') return + mode = 'idle' + detachMapListeners() + setCursor('') + setSelectActive(true) + generation++ + clearPreview() + showOSD('') + await finalise(event.coordinate) + } + + const start = () => { + reset() + if (!elevationService.setSource(map)) { + showOSD('No terrain layer available') + setTimeout(() => showOSD(''), 3000) + return + } + engine.init().then(backend => { + if (backend === 'cpu') console.warn('[AoS] WebGPU unavailable — CPU fallback active') + }) + mode = 'tracking' + setCursor('crosshair') + setSelectActive(false) + showOSD('AoS: move cursor to preview, click to place observer') + clickKey = map.on('singleclick', onSingleClick) + moveKey = map.on('pointermove', onPointerMove) + } + + services.emitter.on('AREA_OF_SIGHT', () => { + services.emitter.emit('command/draw/cancel', { originatorId: ORIGINATOR_ID }) + start() + }) + + services.emitter.on('command/draw/cancel', ({ originatorId }) => { + if (originatorId !== ORIGINATOR_ID) reset() + }) +} diff --git a/test/renderer/viewshed-test.js b/test/renderer/viewshed-test.js new file mode 100644 index 00000000..3c6cf1fa --- /dev/null +++ b/test/renderer/viewshed-test.js @@ -0,0 +1,77 @@ +import assert from 'assert' +import { + viewshedCPU, + maskWindow, + VISIBLE, + HIDDEN, + NO_DATA +} from '../../src/renderer/ol/interaction/area-of-sight/engine' + +const makeGrid = (size, elevation) => ({ + data: new Float32Array(size * size).fill(elevation), + width: size, + height: size +}) + +const params = overrides => ({ + ox: 50, + oy: 50, + radius: 40, + metersPerCell: 10, + observerHeight: 2, + targetHeight: 2, + ...overrides +}) + +const at = (result, x, y) => result.mask[(y - result.y0) * result.w + (x - result.x0)] + +describe('viewshedCPU', function () { + it('sees everything on flat terrain', function () { + const grid = makeGrid(101, 100) + const result = viewshedCPU(grid, params()) + + assert.strictEqual(result.w, 81) + assert.strictEqual(result.h, 81) + let hidden = 0 + let visible = 0 + for (const value of result.mask) { + if (value === HIDDEN) hidden++ + else if (value === VISIBLE) visible++ + } + assert.strictEqual(hidden, 0, 'no cell is hidden on a flat plane') + assert.ok(visible > 0.9 * result.mask.length, 'nearly all cells are covered by rays') + }) + + it('hides cells behind a wall, wall itself stays visible', function () { + const grid = makeGrid(101, 100) + for (let y = 0; y < 101; y++) grid.data[y * 101 + 60] = 200 // N-S wall at x=60 + + const result = viewshedCPU(grid, params()) + + assert.strictEqual(at(result, 55, 50), VISIBLE, 'before the wall') + assert.strictEqual(at(result, 60, 50), VISIBLE, 'target on top of the wall') + assert.strictEqual(at(result, 70, 50), HIDDEN, 'behind the wall') + assert.strictEqual(at(result, 40, 50), VISIBLE, 'opposite direction unaffected') + }) + + it('treats no-data cells as neither visible nor blocking', function () { + const grid = makeGrid(101, 100) + grid.data[50 * 101 + 55] = NO_DATA + + const result = viewshedCPU(grid, params()) + + assert.strictEqual(at(result, 55, 50), 0, 'no-data cell is unclassified') + assert.strictEqual(at(result, 60, 50), VISIBLE, 'cell behind no-data is still visible') + }) + + it('returns null when the observer has no elevation data', function () { + const grid = makeGrid(101, 100) + grid.data[50 * 101 + 50] = NO_DATA + assert.strictEqual(viewshedCPU(grid, params()), null) + }) + + it('clamps the mask window to the grid', function () { + const window = maskWindow(101, 101, 10, 10, 40) + assert.deepStrictEqual(window, { x0: 0, y0: 0, w: 51, h: 51 }) + }) +}) From 3580b9f113cb8eaa02ffead8f2e3572b329b7849 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 16:55:25 +0200 Subject: [PATCH 06/10] feat(analysis): integrate LoS/AoS into the standard feature pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoS and AoS documents are now plain GeoJSON features flowing through featureSource — one feature per document, like measure. This replaces the parallel rendering paths (private layers, own store sync, multiple OL features sharing one id) and brings the standard machinery for free: delete, undo, hide/show, lock, vertex modify and selection. - LoS: LineString observer→target; the async sight-line analysis lives in the style orchestrator (ol/style/los.js) — renders as pending line until the profile arrives, recomputes on geometry/height changes and once terrain becomes available (bridge: ol/style/losCompute.js) - AoS: Point observer with radius/height properties; observer point and radius rim are pipeline styles (ol/style/aos.js), the visibility raster stays in the interaction and now mirrors hidden state (including temporary reveal while highlighted in search) - sidebar/search: options/documents handlers for both scopes with name, distance/radius description, rename and tagging - properties panels operate on GeoJSON properties - pre-pipeline documents are migrated to GeoJSON on startup --- .../properties/AreaOfSightProperties.js | 21 +- .../properties/LineOfSightProperties.js | 11 +- src/renderer/model/sources/featureSource.js | 8 +- .../model/sources/highlightTracker.js | 4 +- .../ol/interaction/area-of-sight/index.js | 148 +++++++----- .../ol/interaction/line-of-sight/index.js | 220 +++++------------- src/renderer/ol/style/aos.js | 38 +++ src/renderer/ol/style/los.js | 123 ++++++++++ src/renderer/ol/style/losCompute.js | 26 +++ src/renderer/ol/style/styles.js | 4 + src/renderer/store/DocumentStore.js | 4 + src/renderer/store/OptionStore.js | 4 + src/renderer/store/documents/aos.js | 18 ++ src/renderer/store/documents/los.js | 18 ++ src/renderer/store/options/aos.js | 30 +++ src/renderer/store/options/los.js | 32 +++ test/renderer/los-style-test.js | 100 ++++++++ 17 files changed, 577 insertions(+), 232 deletions(-) create mode 100644 src/renderer/ol/style/aos.js create mode 100644 src/renderer/ol/style/los.js create mode 100644 src/renderer/ol/style/losCompute.js create mode 100644 src/renderer/store/documents/aos.js create mode 100644 src/renderer/store/documents/los.js create mode 100644 src/renderer/store/options/aos.js create mode 100644 src/renderer/store/options/los.js create mode 100644 test/renderer/los-style-test.js diff --git a/src/renderer/components/properties/AreaOfSightProperties.js b/src/renderer/components/properties/AreaOfSightProperties.js index 7d8d833a..14a66b2a 100644 --- a/src/renderer/components/properties/AreaOfSightProperties.js +++ b/src/renderer/components/properties/AreaOfSightProperties.js @@ -7,32 +7,35 @@ import { DEFAULT_RADIUS_M, MAX_RADIUS_M } from '../../ol/interaction/area-of-sig const formatNumber = value => (typeof value === 'number' ? String(value) : '') -const setHeight = key => value => feature => { +const setProperty = (key, valid) => value => feature => { const num = parseFloat(value) - if (!Number.isFinite(num) || num < 0) return feature - return { ...feature, [key]: num } + if (!valid(num)) return feature + return { ...feature, properties: { ...feature.properties, [key]: num } } } const Radius = textProperty({ label: `Radius [m] (max ${MAX_RADIUS_M})`, - get: feature => formatNumber(feature.radius ?? DEFAULT_RADIUS_M), + get: feature => formatNumber(feature.properties?.radius ?? DEFAULT_RADIUS_M), set: value => feature => { const num = parseFloat(value) if (!Number.isFinite(num) || num < 100) return feature - return { ...feature, radius: Math.min(num, MAX_RADIUS_M) } + return { + ...feature, + properties: { ...feature.properties, radius: Math.min(num, MAX_RADIUS_M) } + } } }) const ObserverHeight = textProperty({ label: 'Observer height [m]', - get: feature => formatNumber(feature.observerHeight), - set: setHeight('observerHeight') + get: feature => formatNumber(feature.properties?.observerHeight), + set: setProperty('observerHeight', num => Number.isFinite(num) && num >= 0) }) const TargetHeight = textProperty({ label: 'Target height [m]', - get: feature => formatNumber(feature.targetHeight), - set: setHeight('targetHeight') + get: feature => formatNumber(feature.properties?.targetHeight), + set: setProperty('targetHeight', num => Number.isFinite(num) && num >= 0) }) const AreaOfSightProperties = (props) => ( diff --git a/src/renderer/components/properties/LineOfSightProperties.js b/src/renderer/components/properties/LineOfSightProperties.js index fec02860..c112029a 100644 --- a/src/renderer/components/properties/LineOfSightProperties.js +++ b/src/renderer/components/properties/LineOfSightProperties.js @@ -11,24 +11,25 @@ const formatHeight = h => (typeof h === 'number' ? h.toFixed(2) : '') const setHeight = key => value => feature => { const num = parseFloat(value) if (!Number.isFinite(num) || num < 0) return feature - return { ...feature, [key]: num } + return { ...feature, properties: { ...feature.properties, [key]: num } } } const ObserverHeight = textProperty({ label: 'Observer height [m]', - get: feature => formatHeight(feature.observerHeight), + get: feature => formatHeight(feature.properties?.observerHeight), set: setHeight('observerHeight') }) const TargetHeight = textProperty({ label: 'Target height [m]', - get: feature => formatHeight(feature.targetHeight), + get: feature => formatHeight(feature.properties?.targetHeight), set: setHeight('targetHeight') }) const distanceKm = (doc) => { - if (!doc?.observer || !doc?.target) return null - return getLength(new LineString([doc.observer, doc.target])) / 1000 + const coordinates = doc?.geometry?.type === 'LineString' && doc.geometry.coordinates + if (!coordinates || coordinates.length < 2) return null + return getLength(new LineString(coordinates)) / 1000 } const LineOfSightProperties = (props) => { diff --git a/src/renderer/model/sources/featureSource.js b/src/renderer/model/sources/featureSource.js index adb22264..9e1c00d2 100644 --- a/src/renderer/model/sources/featureSource.js +++ b/src/renderer/model/sources/featureSource.js @@ -80,7 +80,9 @@ const ord = R.cond([ [R.T, R.always(4)] ]) -const isCandidateId = id => ID.isFeatureId(id) || ID.isMarkerId(id) || ID.isMeasureId(id) +const isCandidateId = id => + ID.isFeatureId(id) || ID.isMarkerId(id) || ID.isMeasureId(id) || + ID.isLosId(id) || ID.isAosId(id) const operations = R.compose( flat, @@ -123,7 +125,9 @@ export const featureSource = services => { const tuples = [ ...await store.tuples(ID.FEATURE_SCOPE), ...await store.tuples(ID.MARKER_SCOPE), - ...await store.tuples(ID.MEASURE_SCOPE) + ...await store.tuples(ID.MEASURE_SCOPE), + ...await store.tuples(ID.LOS_SCOPE), + ...await store.tuples(ID.AOS_SCOPE) ] // Filter out entries with invalid geometry before parsing diff --git a/src/renderer/model/sources/highlightTracker.js b/src/renderer/model/sources/highlightTracker.js index f0980c78..f491e849 100644 --- a/src/renderer/model/sources/highlightTracker.js +++ b/src/renderer/model/sources/highlightTracker.js @@ -19,7 +19,9 @@ export const highlightTracker = (emitter, store, sessionStore) => { const features = geometries.map(geometry => new Feature(geometry)) source.addFeatures(features) // Temporarily show hidden feature. - const isHidable = id => ID.isFeatureId(id) || ID.isMarkerId(id) || ID.isMeasureId(id) + const isHidable = id => + ID.isFeatureId(id) || ID.isMarkerId(id) || ID.isMeasureId(id) || + ID.isLosId(id) || ID.isAosId(id) const keys = await store.collectKeys(ids) const featureIds = keys.filter(isHidable) diff --git a/src/renderer/ol/interaction/area-of-sight/index.js b/src/renderer/ol/interaction/area-of-sight/index.js index cc036700..970d8d1b 100644 --- a/src/renderer/ol/interaction/area-of-sight/index.js +++ b/src/renderer/ol/interaction/area-of-sight/index.js @@ -1,20 +1,16 @@ -import Feature from 'ol/Feature' -import Point from 'ol/geom/Point' -import { Vector as VectorSource } from 'ol/source' -import { Vector as VectorLayer, Image as ImageLayer } from 'ol/layer' +import { Image as ImageLayer } from 'ol/layer' import ImageCanvas from 'ol/source/ImageCanvas' import { Select } from 'ol/interaction' import { unByKey } from 'ol/Observable' import { toLonLat } from 'ol/proj' import { containsExtent } from 'ol/extent' import uuid from '../../../../shared/uuid' +import { militaryFormat } from '../../../../shared/datetime' import * as ID from '../../../ids' import { ElevationService } from '../../../model/ElevationService' import { ViewshedEngine, VISIBLE, HIDDEN, NO_DATA } from './engine' -import { observerPointStyle } from '../line-of-sight/style' const ORIGINATOR_ID = uuid() -const AOS_DOC_TYPE = 'aos' export const DEFAULT_RADIUS_M = 2500 export const MAX_RADIUS_M = 10000 @@ -24,6 +20,16 @@ const DEFAULT_TARGET_HEIGHT_M = 2 const VISIBLE_RGBA = [40, 170, 60, 100] const HIDDEN_RGBA = [200, 40, 40, 100] +/** + * Area-of-Sight tool. Persisted AoS documents are plain GeoJSON features + * (Point observer + radius/height properties) whose vector representation + * (observer point, radius rim) is rendered by the standard feature + * pipeline (style: ol/style/aos.js). This module handles: + * - the placement tool with its live raster preview + * - computing and compositing the visibility rasters (store-driven) + * - hide/show state for the rasters (mirroring visibilityTracker) + * - migrating pre-pipeline documents to GeoJSON + */ export default ({ map, services }) => { const elevationService = new ElevationService() const engine = new ViewshedEngine() @@ -33,8 +39,9 @@ export default ({ map, services }) => { // preview and all persisted viewsheds into the current view. // ──────────────────────────────────────────────────────────── - // aosId -> { doc, canvas, extent, feature } + // aosId -> { doc, canvas, extent } const entries = new Map() + const hiddenIds = new Set() let preview = null // { canvas, extent } const composite = document.createElement('canvas') @@ -55,7 +62,7 @@ export default ({ map, services }) => { (maxY - minY) * scale ) } - entries.forEach(draw) + entries.forEach((entry, id) => { if (!hiddenIds.has(id)) draw(entry) }) draw(preview) return composite } @@ -63,11 +70,6 @@ export default ({ map, services }) => { const rasterSource = new ImageCanvas({ canvasFunction, ratio: 1 }) map.addLayer(new ImageLayer({ source: rasterSource })) - const vectorSource = new VectorSource() - const vector = new VectorLayer({ source: vectorSource, style: null }) - vector.set('selectable', true) - map.addLayer(vector) - /** * Render a mask window into a colorized canvas, clipped to the * circular radius around the observer. @@ -145,11 +147,10 @@ export default ({ map, services }) => { } /** - * Compute viewshed for observer coordinate; returns everything the - * render side needs, or null (no data at observer, no terrain, ...). + * Compute viewshed for observer coordinate + doc properties. */ - const computeViewshed = async (coordinate, doc) => { - const radiusM = Math.min(doc.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) + const computeViewshed = async (coordinate, properties) => { + const radiusM = Math.min(properties.radius ?? DEFAULT_RADIUS_M, MAX_RADIUS_M) const g = await ensureGrid(coordinate, radiusM) if (!g) return null @@ -165,8 +166,8 @@ export default ({ map, services }) => { oy, radius, metersPerCell, - observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, - targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M + observerHeight: properties.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: properties.targetHeight ?? DEFAULT_TARGET_HEIGHT_M }) if (!result) return null @@ -180,37 +181,31 @@ export default ({ map, services }) => { // Persisted AoS rendering (store-driven) // ──────────────────────────────────────────────────────────── - const removePersistedAos = (aosId) => { - const entry = entries.get(aosId) - if (!entry) return - if (entry.feature) vectorSource.removeFeature(entry.feature) - entries.delete(aosId) - rasterSource.changed() - } + const observerOf = doc => doc?.geometry?.type === 'Point' ? doc.geometry.coordinates : null const sameCoord = (a, b) => a && b && a[0] === b[0] && a[1] === b[1] + // Only analysis-relevant parts trigger a recompute (rename does not). const docChanged = (a, b) => !a || !b || - a.radius !== b.radius || - a.observerHeight !== b.observerHeight || - a.targetHeight !== b.targetHeight || - !sameCoord(a.observer, b.observer) + a.properties?.radius !== b.properties?.radius || + a.properties?.observerHeight !== b.properties?.observerHeight || + a.properties?.targetHeight !== b.properties?.targetHeight || + !sameCoord(observerOf(a), observerOf(b)) + + const removePersistedAos = (aosId) => { + if (!entries.delete(aosId)) return + rasterSource.changed() + } const renderPersistedAos = async (aosId, doc) => { + const observer = observerOf(doc) + if (!observer) return if (!elevationService.setSource(map)) return - if (!doc || !doc.observer) return - const rendered = await computeViewshed(doc.observer, doc) + const rendered = await computeViewshed(observer, doc.properties ?? {}) if (!rendered) return - if (entries.has(aosId)) removePersistedAos(aosId) - - const feature = new Feature(new Point(doc.observer)) - feature.setStyle(observerPointStyle) - feature.setId(aosId) - vectorSource.addFeature(feature) - - entries.set(aosId, { doc, ...rendered, feature }) + entries.set(aosId, { doc, ...rendered }) rasterSource.changed() } @@ -225,6 +220,29 @@ export default ({ map, services }) => { } ;(async () => { + // Migration: pre-pipeline docs {observer, radius, heights} → GeoJSON + const tuples = await services.store.tuples(ID.AOS_SCOPE) + const legacy = tuples.filter(([, doc]) => doc && !doc.geometry && doc.observer) + if (legacy.length) { + services.store.insert(legacy.map(([id, doc]) => [id, { + type: 'Feature', + name: `AoS - ${militaryFormat.now()}`, + geometry: { type: 'Point', coordinates: doc.observer }, + properties: { + radius: doc.radius ?? DEFAULT_RADIUS_M, + observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M + } + }])) + } + + // Initial hidden state (mirrors visibilityTracker). + const hiddenKeys = await services.store.keys(ID.hiddenId()) + hiddenKeys + .map(ID.associatedId) + .filter(ID.isAosId) + .forEach(id => hiddenIds.add(id)) + if (await tryInitialLoad()) return const key = map.getLayers().on('add', async () => { if (await tryInitialLoad()) unByKey(key) @@ -233,17 +251,40 @@ export default ({ map, services }) => { services.store.on('batch', ({ operations }) => { for (const op of operations) { + // hide/show tombstones for aos ids + if (ID.isHiddenId(op.key)) { + const id = ID.associatedId(op.key) + if (!ID.isAosId(id)) continue + if (op.type === 'put') hiddenIds.add(id) + else hiddenIds.delete(id) + rasterSource.changed() + continue + } + if (!ID.isAosId(op.key)) continue if (op.type === 'del') { removePersistedAos(op.key) continue } const existing = entries.get(op.key) - if (existing && !docChanged(existing.doc, op.value)) continue + if (existing && !docChanged(existing.doc, op.value)) { + existing.doc = op.value // keep rename etc. without recompute + continue + } renderPersistedAos(op.key, op.value) } }) + // Temporary reveal of hidden features while highlighted in search. + const onTemporaryVisibility = hide => ({ ids }) => { + const relevant = ids.map(ID.associatedId).filter(ID.isAosId) + if (!relevant.length) return + relevant.forEach(id => hide ? hiddenIds.add(id) : hiddenIds.delete(id)) + rasterSource.changed() + } + services.emitter.on('feature/show', onTemporaryVisibility(false)) + services.emitter.on('feature/hide', onTemporaryVisibility(true)) + // ──────────────────────────────────────────────────────────── // Tool lifecycle: viewshed follows the cursor, click to fix // ──────────────────────────────────────────────────────────── @@ -256,7 +297,7 @@ export default ({ map, services }) => { let pending = null let generation = 0 - const liveDoc = { + const liveProperties = { radius: DEFAULT_RADIUS_M, observerHeight: DEFAULT_OBSERVER_HEIGHT_M, targetHeight: DEFAULT_TARGET_HEIGHT_M @@ -284,7 +325,7 @@ export default ({ map, services }) => { const showPreview = async (coordinate) => { const gen = generation - const rendered = await computeViewshed(coordinate, liveDoc) + const rendered = await computeViewshed(coordinate, liveProperties) if (gen !== generation) return preview = rendered rasterSource.changed() @@ -324,19 +365,14 @@ export default ({ map, services }) => { showOSD('') } - const finalise = async (coordinate) => { + const finalise = (coordinate) => { const doc = { - type: AOS_DOC_TYPE, - observer: coordinate, - radius: liveDoc.radius, - observerHeight: liveDoc.observerHeight, - targetHeight: liveDoc.targetHeight + type: 'Feature', + name: `AoS - ${militaryFormat.now()}`, + geometry: { type: 'Point', coordinates: coordinate }, + properties: { ...liveProperties } } - // Render as persisted entry; store insert echoes back via batch but - // docChanged() will skip the redundant recompute. - const aosId = ID.aosId() - await renderPersistedAos(aosId, doc) - services.store.insert([[aosId, doc]]) + services.store.insert([[ID.aosId(), doc]]) } const onPointerMove = (event) => { @@ -344,7 +380,7 @@ export default ({ map, services }) => { track(event.coordinate) } - const onSingleClick = async (event) => { + const onSingleClick = (event) => { if (mode !== 'tracking') return mode = 'idle' detachMapListeners() @@ -353,7 +389,7 @@ export default ({ map, services }) => { generation++ clearPreview() showOSD('') - await finalise(event.coordinate) + finalise(event.coordinate) } const start = () => { diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index 216728cc..f19a3242 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -6,8 +6,10 @@ import { Vector as VectorLayer } from 'ol/layer' import { Select } from 'ol/interaction' import { unByKey } from 'ol/Observable' import uuid from '../../../../shared/uuid' +import { militaryFormat } from '../../../../shared/datetime' import * as ID from '../../../ids' import { ElevationService } from '../../../model/ElevationService' +import { setComputer } from '../../style/losCompute' import { computeLineOfSight, DEFAULT_OBSERVER_HEIGHT_M, @@ -22,23 +24,23 @@ import { } from './style' const ORIGINATOR_ID = uuid() -const LOS_DOC_TYPE = 'los' +/** + * Line-of-Sight tool. Persisted LoS documents are plain GeoJSON features + * (LineString observer→target) rendered by the standard feature pipeline + * (style: ol/style/los.js) — this module only handles: + * - the placement tool with its live preview overlay + * - registering the profile computer once terrain is available + * - migrating pre-pipeline documents to GeoJSON + */ export default ({ map, services }) => { const elevationService = new ElevationService() + // In-progress (live-preview) overlay during placement. const source = new VectorSource() const vector = new VectorLayer({ source, style: null }) - vector.set('selectable', true) map.addLayer(vector) - // Per persisted LoS, keyed by losId. Each entry: - // { doc, features: { observer, visible, blocked?, blocker?, clip? } } - // `doc` is the last value used to render so we can detect actual changes - // (heights, observer, target) on store batch put operations. - const featuresByLosId = new Map() - - // In-progress (live-preview) features. Hand-over to featuresByLosId on finalise. let visibleSegmentFeature = null let blockedSegmentFeature = null let observerFeature = null @@ -62,7 +64,42 @@ export default ({ map, services }) => { const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) // ──────────────────────────────────────────────────────────── - // In-progress overlay (live preview during placement) + // Terrain discovery: the los style computes through this bridge. + // ──────────────────────────────────────────────────────────── + + const tryEnableComputer = () => { + if (!elevationService.setSource(map)) return false + setComputer(params => computeLineOfSight({ ...params, elevationService })) + return true + } + + if (!tryEnableComputer()) { + const key = map.getLayers().on('add', () => { + if (tryEnableComputer()) unByKey(key) + }) + } + + // ──────────────────────────────────────────────────────────── + // Migration: pre-pipeline docs {observer, target, heights} → GeoJSON + // ──────────────────────────────────────────────────────────── + + (async () => { + const tuples = await services.store.tuples(ID.LOS_SCOPE) + const legacy = tuples.filter(([, doc]) => doc && !doc.geometry && doc.observer && doc.target) + if (!legacy.length) return + services.store.insert(legacy.map(([id, doc]) => [id, { + type: 'Feature', + name: `LoS - ${militaryFormat.now()}`, + geometry: { type: 'LineString', coordinates: [doc.observer, doc.target] }, + properties: { + observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M + } + }])) + })() + + // ──────────────────────────────────────────────────────────── + // Live preview overlay // ──────────────────────────────────────────────────────────── const removeFeatureIfPresent = (feature) => { @@ -165,122 +202,6 @@ export default ({ map, services }) => { return result } - // ──────────────────────────────────────────────────────────── - // Persisted LoS rendering (store-driven) - // ──────────────────────────────────────────────────────────── - - const buildFeaturesFromResult = (losId, result) => { - const { samples, firstBlocker, clipped } = result - const observerCoord = samples[0].coordinate - const lastCoord = samples[samples.length - 1].coordinate - - const visibleEndIdx = firstBlocker ? firstBlocker.index : samples.length - 1 - const visibleCoords = samples.slice(0, visibleEndIdx + 1).map(s => s.coordinate) - - const features = {} - - features.observer = new Feature(new Point(observerCoord)) - features.observer.setStyle(observerPointStyle) - features.observer.setId(losId) - source.addFeature(features.observer) - - features.visible = new Feature(new LineString(visibleCoords)) - features.visible.setStyle(visibleSegmentStyle) - features.visible.setId(losId) - source.addFeature(features.visible) - - if (firstBlocker) { - const blockedCoords = samples.slice(firstBlocker.index).map(s => s.coordinate) - features.blocked = new Feature(new LineString(blockedCoords)) - features.blocked.setStyle(blockedSegmentStyle) - features.blocked.setId(losId) - source.addFeature(features.blocked) - - features.blocker = new Feature(new Point(firstBlocker.coordinate)) - features.blocker.setStyle(blockerPointStyle) - features.blocker.setId(losId) - source.addFeature(features.blocker) - } - - if (clipped) { - features.clip = new Feature(new Point(lastCoord)) - features.clip.setStyle(clipMarkerStyle) - features.clip.setId(losId) - source.addFeature(features.clip) - } - - return features - } - - const sameCoord = (a, b) => a && b && a[0] === b[0] && a[1] === b[1] - - const docChanged = (a, b) => - !a || !b || - a.observerHeight !== b.observerHeight || - a.targetHeight !== b.targetHeight || - !sameCoord(a.observer, b.observer) || - !sameCoord(a.target, b.target) - - const renderPersistedLos = async (losId, doc) => { - if (!elevationService.setSource(map)) return - if (!doc || !doc.observer || !doc.target) return - - const result = await computeLineOfSight({ - observer: doc.observer, - target: doc.target, - observerHeight: doc.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, - targetHeight: doc.targetHeight ?? DEFAULT_TARGET_HEIGHT_M, - elevationService - }) - if (!result) return - // A concurrent path may have rendered it while we awaited; if so, - // drop the existing features so we win and stay consistent with `doc`. - if (featuresByLosId.has(losId)) removePersistedLos(losId) - - const features = buildFeaturesFromResult(losId, result) - featuresByLosId.set(losId, { doc, features }) - } - - const removePersistedLos = (losId) => { - const entry = featuresByLosId.get(losId) - if (!entry) return - Object.values(entry.features).forEach(f => f && source.removeFeature(f)) - featuresByLosId.delete(losId) - } - - const tryInitialLoad = async () => { - if (!elevationService.setSource(map)) return false - const tuples = await services.store.tuples(ID.LOS_SCOPE) - for (const [id, doc] of tuples) { - const existing = featuresByLosId.get(id) - if (!existing || docChanged(existing.doc, doc)) renderPersistedLos(id, doc) - } - return true - } - - ;(async () => { - if (await tryInitialLoad()) return - // Terrain not available yet — retry once a layer is added. - const key = map.getLayers().on('add', async () => { - if (await tryInitialLoad()) unByKey(key) - }) - })() - - services.store.on('batch', ({ operations }) => { - for (const op of operations) { - if (!ID.isLosId(op.key)) continue - if (op.type === 'del') { - removePersistedLos(op.key) - continue - } - // put: skip when the stored doc matches what we already rendered - // (e.g. our own self-echo right after finalise). - const existing = featuresByLosId.get(op.key) - if (existing && !docChanged(existing.doc, op.value)) continue - renderPersistedLos(op.key, op.value) - } - }) - // ──────────────────────────────────────────────────────────── // Tool lifecycle // ──────────────────────────────────────────────────────────── @@ -315,42 +236,23 @@ export default ({ map, services }) => { const finalise = async (coordinate) => { const result = await recompute(coordinate) - if (!result || !observerFeature || !visibleSegmentFeature) { - clearInProgressOverlay() - return - } - - // Hand the in-progress features over as a persistent entry; assigning - // the losId lets the select-interaction map clicks on any sub-feature - // back to the same document. - const losId = ID.losId() - const features = { - observer: observerFeature, - visible: visibleSegmentFeature, - blocked: blockedSegmentFeature, - blocker: blockerFeature, - clip: clipMarkerFeature - } - Object.values(features).forEach(f => f && f.setId(losId)) + clearInProgressOverlay() + if (!result) return - // Persist using the clamped target (so re-render after reload is identical). - const persistedTarget = result.samples[result.samples.length - 1].coordinate + // Persist observer→clamped target; the feature pipeline renders it. const doc = { - type: LOS_DOC_TYPE, - observer: result.samples[0].coordinate, - target: persistedTarget, - observerHeight, - targetHeight + type: 'Feature', + name: `LoS - ${militaryFormat.now()}`, + geometry: { + type: 'LineString', + coordinates: [ + result.samples[0].coordinate, + result.samples[result.samples.length - 1].coordinate + ] + }, + properties: { observerHeight, targetHeight } } - featuresByLosId.set(losId, { doc, features }) - - visibleSegmentFeature = null - blockedSegmentFeature = null - observerFeature = null - blockerFeature = null - clipMarkerFeature = null - - services.store.insert([[losId, doc]]) + services.store.insert([[ID.losId(), doc]]) } const onPointerMove = (event) => { diff --git a/src/renderer/ol/style/aos.js b/src/renderer/ol/style/aos.js new file mode 100644 index 00000000..8ee3a444 --- /dev/null +++ b/src/renderer/ol/style/aos.js @@ -0,0 +1,38 @@ +import Signal from '@syncpoint/signal' +import { Style, Stroke, Fill, Circle as CircleStyle } from 'ol/style' +import CircleGeom from 'ol/geom/Circle' +import { toLonLat } from 'ol/proj' + +const DEFAULT_RADIUS_M = 2500 + +const observerImage = selected => new CircleStyle({ + radius: selected ? 8 : 7, + fill: new Fill({ color: 'rgba(0, 120, 220, 0.95)' }), + stroke: new Stroke({ color: '#fff', width: selected ? 3 : 2 }) +}) + +const rimStroke = selected => new Stroke({ + color: selected ? 'rgba(0, 120, 220, 0.9)' : 'rgba(0, 120, 220, 0.55)', + width: selected ? 2.5 : 1.5, + lineDash: [6, 8] +}) + +/** + * Style orchestrator for Area-of-Sight features: observer point plus a + * dashed rim at the analysis radius. The visibility raster itself is + * rendered by the area-of-sight interaction's image layer. + */ +export default $ => Signal.link( + (geometry, properties, mode) => { + const selected = mode !== 'default' + const center = geometry.getCoordinates() + const radius = properties?.radius ?? DEFAULT_RADIUS_M + const lat = toLonLat(center)[1] * Math.PI / 180 + const projected = radius / Math.max(0.087, Math.cos(lat)) + return [ + new Style({ stroke: rimStroke(selected), geometry: new CircleGeom(center, projected), zIndex: 1 }), + new Style({ image: observerImage(selected), zIndex: 10 }) + ] + }, + [$.geometry, $.properties, $.selectionMode] +) diff --git a/src/renderer/ol/style/los.js b/src/renderer/ol/style/los.js new file mode 100644 index 00000000..e4fdaa19 --- /dev/null +++ b/src/renderer/ol/style/los.js @@ -0,0 +1,123 @@ +import Signal from '@syncpoint/signal' +import { Style, Stroke, Fill, Circle as CircleStyle, RegularShape } from 'ol/style' +import Point from 'ol/geom/Point' +import MultiPoint from 'ol/geom/MultiPoint' +import LineString from 'ol/geom/LineString' +import { compute, registerInvalidator } from './losCompute' + +const DEFAULT_HEIGHT_M = 1.7 + +const visibleStroke = new Stroke({ color: 'rgba(0, 180, 60, 0.95)', width: 4 }) +const blockedStroke = new Stroke({ color: 'rgba(220, 30, 30, 0.95)', width: 4, lineDash: [8, 6] }) +const pendingStroke = new Stroke({ color: 'rgba(120, 120, 120, 0.8)', width: 3, lineDash: [4, 6] }) +const haloStroke = new Stroke({ color: 'rgba(255, 255, 255, 0.85)', width: 7 }) + +const observerImage = new CircleStyle({ + radius: 7, + fill: new Fill({ color: 'rgba(0, 120, 220, 0.95)' }), + stroke: new Stroke({ color: '#fff', width: 2 }) +}) + +const blockerImage = new RegularShape({ + points: 4, + radius: 9, + angle: Math.PI / 4, + fill: new Fill({ color: 'rgba(220, 30, 30, 0.95)' }), + stroke: new Stroke({ color: '#fff', width: 2 }) +}) + +const clipImage = new CircleStyle({ + radius: 5, + fill: new Fill({ color: 'rgba(220, 140, 0, 0.9)' }), + stroke: new Stroke({ color: '#fff', width: 2 }) +}) + +const handleImage = new CircleStyle({ + radius: 5, + fill: new Fill({ color: 'white' }), + stroke: new Stroke({ color: 'rgba(0, 120, 220, 0.95)', width: 2 }) +}) + +const buildStyles = (geometry, result, selected) => { + const coords = geometry.getCoordinates() + if (coords.length < 2) return [] + + const styles = [] + const push = options => styles.push(new Style(options)) + + if (selected) push({ stroke: haloStroke, geometry, zIndex: 0 }) + + if (!result) { + // no terrain available (yet) or observer/target without data + push({ stroke: pendingStroke, geometry, zIndex: 1 }) + } else { + const { samples, firstBlocker, clipped } = result + const visibleEnd = firstBlocker ? firstBlocker.index : samples.length - 1 + const coordsOf = xs => xs.map(s => s.coordinate) + + push({ + stroke: visibleStroke, + geometry: new LineString(coordsOf(samples.slice(0, visibleEnd + 1))), + zIndex: 1 + }) + + if (firstBlocker) { + push({ + stroke: blockedStroke, + geometry: new LineString(coordsOf(samples.slice(firstBlocker.index))), + zIndex: 1 + }) + push({ image: blockerImage, geometry: new Point(firstBlocker.coordinate), zIndex: 5 }) + } + + if (clipped) { + const last = samples[samples.length - 1].coordinate + // remainder beyond the 10 km analysis range + push({ stroke: pendingStroke, geometry: new LineString([last, coords[coords.length - 1]]), zIndex: 1 }) + push({ image: clipImage, geometry: new Point(last), zIndex: 5 }) + } + } + + push({ image: observerImage, geometry: new Point(coords[0]), zIndex: 10 }) + if (selected) { + push({ image: handleImage, geometry: new MultiPoint([coords[0], coords[coords.length - 1]]), zIndex: 11 }) + } + + return styles +} + +/** + * Style orchestrator for Line-of-Sight features. The sight-line analysis + * is asynchronous (terrain tiles); $.losResult starts as null (rendered + * as pending line) and is pushed when the profile arrives. + */ +export default ($, featureId) => { + $.losResult = Signal.of(null) + $.selected = $.selectionMode.map(mode => mode !== 'default') + + let generation = 0 + let latest = null + + const recompute = ({ geometry, properties }) => { + const coords = geometry.getCoordinates() + if (coords.length < 2) return + const gen = ++generation + compute({ + observer: coords[0], + target: coords[coords.length - 1], + observerHeight: properties?.observerHeight ?? DEFAULT_HEIGHT_M, + targetHeight: properties?.targetHeight ?? DEFAULT_HEIGHT_M + }).then(result => { + if (gen !== generation) return // superseded by a newer geometry/height + $.losResult(result ?? null) + }) + } + + Signal.link((geometry, properties) => ({ geometry, properties }), [$.geometry, $.properties]) + .on(input => { latest = input; recompute(input) }) + + // restyle once terrain becomes available after the feature was loaded + registerInvalidator(featureId, () => latest && recompute(latest)) + + return Signal.link(buildStyles, [$.geometry, $.losResult, $.selected]) +} diff --git a/src/renderer/ol/style/losCompute.js b/src/renderer/ol/style/losCompute.js new file mode 100644 index 00000000..6412f55e --- /dev/null +++ b/src/renderer/ol/style/losCompute.js @@ -0,0 +1,26 @@ +/** + * Bridge between the Line-of-Sight interaction (which owns the map and + * the elevation service) and the los style orchestrator (which only has + * per-feature signals). + * + * The interaction registers a computer once a terrain layer is + * available; each los feature's style registers an invalidator so + * pending features restyle as soon as terrain arrives. + */ + +let computer = null // params -> Promise +const invalidators = new Map() // featureId -> () => void + +/** + * @param {(params: {observer, target, observerHeight, targetHeight}) => Promise} fn + */ +export const setComputer = fn => { + computer = fn + invalidators.forEach(invalidate => invalidate()) +} + +export const compute = params => + computer ? computer(params) : Promise.resolve(null) + +export const registerInvalidator = (featureId, fn) => + invalidators.set(featureId, fn) diff --git a/src/renderer/ol/style/styles.js b/src/renderer/ol/style/styles.js index a3b2b7a4..0f06bbbf 100644 --- a/src/renderer/ol/style/styles.js +++ b/src/renderer/ol/style/styles.js @@ -10,6 +10,8 @@ import multipoint from './multipoint' import corridor from './corridor' import marker from './marker' import measure from './measure' +import los from './los' +import aos from './aos' import shape from './shape' import textShape from './text-shape' import fallback from './fallback' @@ -54,6 +56,8 @@ export default feature => { if (ID.isMarkerId(featureId)) return marker($) else if (ID.isMeasureId(featureId)) return measure($) + else if (ID.isLosId(featureId)) return los($, featureId) + else if (ID.isAosId(featureId)) return aos($) else if (isTextShape) return textShape($) else if (isShape) return shape($) else if (geometryType === 'Point') return symbol($) diff --git a/src/renderer/store/DocumentStore.js b/src/renderer/store/DocumentStore.js index b2cbc947..0255d8ae 100644 --- a/src/renderer/store/DocumentStore.js +++ b/src/renderer/store/DocumentStore.js @@ -8,6 +8,8 @@ import sseService from './documents/sse-service' import bookmark from './documents/bookmark' import place from './documents/place' import measure from './documents/measure' +import los from './documents/los' +import aos from './documents/aos' import invited from './documents/invited' export default function DocumentStore (store) { @@ -22,6 +24,8 @@ DocumentStore.prototype['link+feature'] = DocumentStore.prototype.link DocumentStore.prototype.symbol = symbol DocumentStore.prototype.marker = marker DocumentStore.prototype.measure = measure +DocumentStore.prototype.los = los +DocumentStore.prototype.aos = aos DocumentStore.prototype['tile-service'] = tileService DocumentStore.prototype['sse-service'] = sseService DocumentStore.prototype.bookmark = bookmark diff --git a/src/renderer/store/OptionStore.js b/src/renderer/store/OptionStore.js index c27655a5..9781f938 100644 --- a/src/renderer/store/OptionStore.js +++ b/src/renderer/store/OptionStore.js @@ -8,6 +8,8 @@ import sseService from './options/sse-service' import bookmark from './options/bookmark' import place from './options/place' import measure from './options/measure' +import los from './options/los' +import aos from './options/aos' import invited from './options/invited' export default function OptionStore (coordinatesFormat, store, sessionStore) { @@ -28,4 +30,6 @@ OptionStore.prototype['sse-service'] = sseService OptionStore.prototype.bookmark = bookmark OptionStore.prototype.place = place OptionStore.prototype.measure = measure +OptionStore.prototype.los = los +OptionStore.prototype.aos = aos OptionStore.prototype.invited = invited diff --git a/src/renderer/store/documents/aos.js b/src/renderer/store/documents/aos.js new file mode 100644 index 00000000..86574893 --- /dev/null +++ b/src/renderer/store/documents/aos.js @@ -0,0 +1,18 @@ +import * as R from 'ramda' +import * as ID from '../../ids' + +/** + * Document handler for Area-of-Sight entities (search index). + * @this {Object} Context with store property + */ +export default async function (id) { + const keys = [R.identity, ID.tagsId] + const [aos, tags] = await this.store.collect(id, keys) + + return { + id, + scope: ID.AOS, + text: aos?.name || '', + tags: tags || [] + } +} diff --git a/src/renderer/store/documents/los.js b/src/renderer/store/documents/los.js new file mode 100644 index 00000000..9c080a46 --- /dev/null +++ b/src/renderer/store/documents/los.js @@ -0,0 +1,18 @@ +import * as R from 'ramda' +import * as ID from '../../ids' + +/** + * Document handler for Line-of-Sight entities (search index). + * @this {Object} Context with store property + */ +export default async function (id) { + const keys = [R.identity, ID.tagsId] + const [los, tags] = await this.store.collect(id, keys) + + return { + id, + scope: ID.LOS, + text: los?.name || '', + tags: tags || [] + } +} diff --git a/src/renderer/store/options/aos.js b/src/renderer/store/options/aos.js new file mode 100644 index 00000000..ce05a925 --- /dev/null +++ b/src/renderer/store/options/aos.js @@ -0,0 +1,30 @@ +import * as R from 'ramda' +import * as ID from '../../ids' + +/** + * Options handler for Area-of-Sight entities (sidebar/search display). + * @this {Object} Context with store property + */ +export default async function (id) { + const keys = [R.identity, ID.hiddenId, ID.lockedId, ID.tagsId] + const [aos, hidden, locked, tags] = await this.store.collect(id, keys) + + const radius = aos?.properties?.radius + const description = typeof radius === 'number' + ? `Radius ${radius >= 1000 ? `${(radius / 1000).toFixed(1)} km` : `${radius} m`}` + : undefined + + return { + id, + title: aos?.name || 'Area of Sight', + description, + tags: [ + 'SCOPE:AOS:NONE', + hidden ? 'SYSTEM:HIDDEN::mdiEyeOff' : 'SYSTEM:VISIBLE::mdiEyeOutline', + locked ? 'SYSTEM:LOCKED::mdiLock' : 'SYSTEM:UNLOCKED::mdiLockOpenVariantOutline', + ...((tags || [])).map(label => `USER:${label}:NONE`), + 'PLUS' + ].join(' '), + capabilities: 'TAG|RENAME' + } +} diff --git a/src/renderer/store/options/los.js b/src/renderer/store/options/los.js new file mode 100644 index 00000000..15011453 --- /dev/null +++ b/src/renderer/store/options/los.js @@ -0,0 +1,32 @@ +import * as R from 'ramda' +import { length } from '../../ol/interaction/measure/tools' +import * as ID from '../../ids' +import LineString from 'ol/geom/LineString' + +/** + * Options handler for Line-of-Sight entities (sidebar/search display). + * @this {Object} Context with store property + */ +export default async function (id) { + const keys = [R.identity, ID.hiddenId, ID.lockedId, ID.tagsId] + const [los, hidden, locked, tags] = await this.store.collect(id, keys) + + const coordinates = los?.geometry?.type === 'LineString' && los.geometry.coordinates + const description = coordinates && coordinates.length >= 2 + ? `Distance ${length(new LineString(coordinates))}` + : undefined + + return { + id, + title: los?.name || 'Line of Sight', + description, + tags: [ + 'SCOPE:LOS:NONE', + hidden ? 'SYSTEM:HIDDEN::mdiEyeOff' : 'SYSTEM:VISIBLE::mdiEyeOutline', + locked ? 'SYSTEM:LOCKED::mdiLock' : 'SYSTEM:UNLOCKED::mdiLockOpenVariantOutline', + ...((tags || [])).map(label => `USER:${label}:NONE`), + 'PLUS' + ].join(' '), + capabilities: 'TAG|RENAME' + } +} diff --git a/test/renderer/los-style-test.js b/test/renderer/los-style-test.js new file mode 100644 index 00000000..81bd7fbc --- /dev/null +++ b/test/renderer/los-style-test.js @@ -0,0 +1,100 @@ +import assert from 'assert' +import Signal from '@syncpoint/signal' +import LineString from 'ol/geom/LineString' +import Point from 'ol/geom/Point' +import losStyle from '../../src/renderer/ol/style/los' +import aosStyle from '../../src/renderer/ol/style/aos' +import { setComputer } from '../../src/renderer/ol/style/losCompute' + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +const makeContext = (geometry, properties = {}) => ({ + properties: Signal.of(properties), + geometry: Signal.of(geometry), + selectionMode: Signal.of('default') +}) + +const fakeResult = () => { + const samples = Array.from({ length: 11 }, (_, i) => ({ + distance: i * 100, + elevation: 100, + coordinate: [i * 100, 0] + })) + return { + distance: 1000, + samples, + firstBlocker: { index: 5, distance: 500, coordinate: [500, 0], elevation: 200 }, + clipped: false + } +} + +describe('los style orchestrator', function () { + afterEach(function () { setComputer(null) }) + + it('emits a pending style immediately when no computer is available', function () { + setComputer(null) + const $ = makeContext(new LineString([[0, 0], [1000, 0]])) + const emitted = [] + losStyle($, 'los:test-pending').on(styles => emitted.push(styles)) + + assert.ok(emitted.length >= 1, 'initial style emitted synchronously') + const styles = emitted[emitted.length - 1] + // pending line + observer point + assert.strictEqual(styles.length, 2) + }) + + it('restyles with segments once the profile computer delivers', async function () { + setComputer(async () => fakeResult()) + const $ = makeContext(new LineString([[0, 0], [1000, 0]])) + const emitted = [] + losStyle($, 'los:test-resolved').on(styles => emitted.push(styles)) + + await tick() + const styles = emitted[emitted.length - 1] + // visible segment + blocked segment + blocker marker + observer point + assert.strictEqual(styles.length, 4) + const geometries = styles.map(s => s.getGeometry()?.getType()) + assert.deepStrictEqual(geometries, ['LineString', 'LineString', 'Point', 'Point']) + }) + + it('recomputes when terrain becomes available later (invalidator)', async function () { + setComputer(null) + const $ = makeContext(new LineString([[0, 0], [1000, 0]])) + const emitted = [] + losStyle($, 'los:test-late-terrain').on(styles => emitted.push(styles)) + await tick() + assert.strictEqual(emitted[emitted.length - 1].length, 2, 'pending before terrain') + + setComputer(async () => fakeResult()) + await tick() + assert.strictEqual(emitted[emitted.length - 1].length, 4, 'resolved after terrain arrived') + }) + + it('recomputes on geometry change', async function () { + let calls = 0 + setComputer(async () => { calls++; return fakeResult() }) + const $ = makeContext(new LineString([[0, 0], [1000, 0]])) + losStyle($, 'los:test-geometry').on(() => {}) + await tick() + const before = calls + $.geometry(new LineString([[0, 0], [2000, 0]])) + await tick() + assert.ok(calls > before, 'geometry change triggered recompute') + }) +}) + +describe('aos style orchestrator', function () { + it('emits observer point and radius rim synchronously', function () { + const $ = makeContext(new Point([1447153, 5955192]), { radius: 3000 }) // ~47°N + const emitted = [] + aosStyle($).on(styles => emitted.push(styles)) + + assert.ok(emitted.length >= 1) + const styles = emitted[emitted.length - 1] + assert.strictEqual(styles.length, 2) + const circle = styles[0].getGeometry() + assert.strictEqual(circle.getType(), 'Circle') + // Mercator-inflated radius: 3000 / cos(47°) ≈ 4400 + assert.ok(circle.getRadius() > 4000 && circle.getRadius() < 4800) + }) +}) From 55733a5549445679f5963c7425a3d2fc7a73e60e Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 19:23:12 +0200 Subject: [PATCH 07/10] feat(analysis): adjustable heights and radius during placement - LoS: arrow up/down adjusts observer height, shift+arrows target height while placing; live preview recomputes, values shown in OSD - AoS: arrow up/down adjusts radius (250 m steps), shift/alt+arrows observer/target height; preview recomputes on change - Escape cancels an active placement - last-used values persist per project (session store) and become the defaults for the next placement; placed objects remain editable via the properties panel --- .../ol/interaction/area-of-sight/index.js | 51 ++++++++++++++++++- .../ol/interaction/line-of-sight/index.js | 42 +++++++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/renderer/ol/interaction/area-of-sight/index.js b/src/renderer/ol/interaction/area-of-sight/index.js index 970d8d1b..2bbc1574 100644 --- a/src/renderer/ol/interaction/area-of-sight/index.js +++ b/src/renderer/ol/interaction/area-of-sight/index.js @@ -296,6 +296,10 @@ export default ({ map, services }) => { let busy = false let pending = null let generation = 0 + let lastCoordinate = null + + const RADIUS_STEP_M = 250 + const MIN_RADIUS_M = 250 const liveProperties = { radius: DEFAULT_RADIUS_M, @@ -310,6 +314,14 @@ export default ({ map, services }) => { const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) + const settingsInfo = () => { + const km = liveProperties.radius >= 1000 + ? `${(liveProperties.radius / 1000).toFixed(2)} km` + : `${liveProperties.radius} m` + return `Radius ${km} ↑↓ | Obs ${liveProperties.observerHeight.toFixed(1)} m ⇧↑↓ | ` + + `Tgt ${liveProperties.targetHeight.toFixed(1)} m ⌥↑↓` + } + const selectInteraction = () => map.getInteractions().getArray().find(i => i instanceof Select) @@ -359,6 +371,7 @@ export default ({ map, services }) => { clearPreview() mode = 'idle' pending = null + lastCoordinate = null generation++ setCursor('') setSelectActive(true) @@ -373,13 +386,42 @@ export default ({ map, services }) => { properties: { ...liveProperties } } services.store.insert([[ID.aosId(), doc]]) + services.sessionStore.put('tools.aos', { ...liveProperties }) } const onPointerMove = (event) => { if (mode !== 'tracking' || event.dragging) return + lastCoordinate = event.coordinate track(event.coordinate) } + // Radius/height adjustment and cancel while the tool is active. + // Capture phase so map keyboard handlers do not interfere. + const onKeyDown = (event) => { + if (mode !== 'tracking') return + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + reset() + return + } + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return + event.preventDefault() + event.stopPropagation() + const up = event.key === 'ArrowUp' + if (event.shiftKey) { + liveProperties.observerHeight = Math.max(0, liveProperties.observerHeight + (up ? 0.5 : -0.5)) + } else if (event.altKey) { + liveProperties.targetHeight = Math.max(0, liveProperties.targetHeight + (up ? 0.5 : -0.5)) + } else { + liveProperties.radius = Math.min(MAX_RADIUS_M, + Math.max(MIN_RADIUS_M, liveProperties.radius + (up ? RADIUS_STEP_M : -RADIUS_STEP_M))) + } + showOSD(`AoS: ${settingsInfo()} | click to place`) + if (lastCoordinate) track(lastCoordinate) + } + document.addEventListener('keydown', onKeyDown, true) + const onSingleClick = (event) => { if (mode !== 'tracking') return mode = 'idle' @@ -392,7 +434,7 @@ export default ({ map, services }) => { finalise(event.coordinate) } - const start = () => { + const start = async () => { reset() if (!elevationService.setSource(map)) { showOSD('No terrain layer available') @@ -402,10 +444,15 @@ export default ({ map, services }) => { engine.init().then(backend => { if (backend === 'cpu') console.warn('[AoS] WebGPU unavailable — CPU fallback active') }) + // last-used settings are the defaults for the next placement + const defaults = await services.sessionStore.get('tools.aos', {}) + liveProperties.radius = defaults.radius ?? DEFAULT_RADIUS_M + liveProperties.observerHeight = defaults.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M + liveProperties.targetHeight = defaults.targetHeight ?? DEFAULT_TARGET_HEIGHT_M mode = 'tracking' setCursor('crosshair') setSelectActive(false) - showOSD('AoS: move cursor to preview, click to place observer') + showOSD(`AoS: ${settingsInfo()} | click to place`) clickKey = map.on('singleclick', onSingleClick) moveKey = map.on('pointermove', onPointerMove) } diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index f19a3242..c0a1c76a 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -50,8 +50,9 @@ export default ({ map, services }) => { /** @type {'idle' | 'placing-observer' | 'tracking-target'} */ let mode = 'idle' let observer = null - const observerHeight = DEFAULT_OBSERVER_HEIGHT_M - const targetHeight = DEFAULT_TARGET_HEIGHT_M + let observerHeight = DEFAULT_OBSERVER_HEIGHT_M + let targetHeight = DEFAULT_TARGET_HEIGHT_M + let lastTarget = null let computeGeneration = 0 let clickKey = null let moveKey = null @@ -63,6 +64,9 @@ export default ({ map, services }) => { const showOSD = message => services.emitter.emit('osd', { message, cell: 'A3' }) + const heightsInfo = () => + `Obs ${observerHeight.toFixed(1)} m ↑↓ | Tgt ${targetHeight.toFixed(1)} m ⇧↑↓` + // ──────────────────────────────────────────────────────────── // Terrain discovery: the los style computes through this bridge. // ──────────────────────────────────────────────────────────── @@ -185,7 +189,7 @@ export default ({ map, services }) => { ? ` | blocked at ${(firstBlocker.distance / 1000).toFixed(2)} km` : ' | clear' const clipInfo = result.clipped ? ' (max 10 km)' : '' - showOSD(`LoS: ${dKm} km${clipInfo} | Δh ${dEye} m${blockerInfo}`) + showOSD(`LoS: ${dKm} km${clipInfo} | Δh ${dEye} m${blockerInfo} | ${heightsInfo()}`) } const recompute = async (target) => { @@ -226,6 +230,7 @@ export default ({ map, services }) => { detachMapListeners() clearInProgressOverlay() observer = null + lastTarget = null mode = 'idle' setCursor('') setSelectActive(true) @@ -253,14 +258,37 @@ export default ({ map, services }) => { properties: { observerHeight, targetHeight } } services.store.insert([[ID.losId(), doc]]) + services.sessionStore.put('tools.los', { observerHeight, targetHeight }) } const onPointerMove = (event) => { if (mode !== 'tracking-target' || !observer) return if (event.dragging) return + lastTarget = event.coordinate recompute(event.coordinate) } + // Height adjustment and cancel while the tool is active. Capture phase + // so map keyboard handlers (Escape deselect, pan) do not interfere. + const onKeyDown = (event) => { + if (mode === 'idle') return + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + reset() + return + } + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return + event.preventDefault() + event.stopPropagation() + const delta = event.key === 'ArrowUp' ? 0.5 : -0.5 + if (event.shiftKey) targetHeight = Math.max(0, targetHeight + delta) + else observerHeight = Math.max(0, observerHeight + delta) + if (mode === 'tracking-target' && lastTarget) recompute(lastTarget) + else showOSD(`LoS: click to place observer | ${heightsInfo()}`) + } + document.addEventListener('keydown', onKeyDown, true) + const onSingleClick = async (event) => { if (mode === 'placing-observer') { observer = event.coordinate @@ -279,17 +307,21 @@ export default ({ map, services }) => { } } - const start = () => { + const start = async () => { reset() if (!elevationService.setSource(map)) { showOSD('No terrain layer available') setTimeout(() => showOSD(''), 3000) return } + // last-used heights are the defaults for the next placement + const defaults = await services.sessionStore.get('tools.los', {}) + observerHeight = defaults.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M + targetHeight = defaults.targetHeight ?? DEFAULT_TARGET_HEIGHT_M mode = 'placing-observer' setCursor('crosshair') setSelectActive(false) - showOSD('LoS: click to place observer') + showOSD(`LoS: click to place observer | ${heightsInfo()}`) clickKey = map.on('singleclick', onSingleClick) moveKey = map.on('pointermove', onPointerMove) } From 348f21b211cfe0bb590a338a7099a9abe8a5567f Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 19:29:15 +0200 Subject: [PATCH 08/10] fix(elevation): handle TileJSON sources that are still loading getTileGrid() returns null until a TileJSON source has fetched its metadata. setSource treated "terrain layer present" as "terrain ready" and the AoS initial load crashed on the null tile grid at startup. - setSource returns false while the tile grid is not available yet - new onTerrainReady(map, attempt) retries when a layer is added AND when a pending source finishes loading; LoS/AoS use it for computer registration and initial document rendering - getGrid/profileAlongLine/elevationAt guard against a missing grid --- src/renderer/model/ElevationService.js | 42 +++++++++++++++---- .../ol/interaction/area-of-sight/index.js | 13 +++--- .../ol/interaction/line-of-sight/index.js | 8 +--- test/renderer/model/ElevationService-test.js | 32 ++++++++++++++ 4 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/renderer/model/ElevationService.js b/src/renderer/model/ElevationService.js index ad253fae..6ff01a24 100644 --- a/src/renderer/model/ElevationService.js +++ b/src/renderer/model/ElevationService.js @@ -1,4 +1,5 @@ import { getLength } from 'ol/sphere' +import { unByKey } from 'ol/Observable' const MAX_CACHE_SIZE = 200 // decoded tiles à 256 KB → ≤ 50 MB const TILE_SIZE = 256 @@ -41,26 +42,50 @@ export function ElevationService () { this.tileCache_ = new Map() // 'z/x/y' -> Promise } +const terrainLayers = map => map.getLayerGroup().getLayersArray() + .filter(l => l.get('contentType') === 'terrain/mapbox-rgb') + /** * Discovers terrain layer from the map and extracts source + tileGrid. + * Returns false while a TileJSON source is still loading its metadata + * (getTileGrid() is null until then). * @param {import('ol/Map').default} map - * @returns {boolean} true if a terrain source was found + * @returns {boolean} true if a ready terrain source was found */ ElevationService.prototype.setSource = function (map) { - const terrainLayers = map.getLayerGroup().getLayersArray() - .filter(l => l.get('contentType') === 'terrain/mapbox-rgb') + const layers = terrainLayers(map) + if (layers.length === 0) return false - if (terrainLayers.length === 0) return false + const source = layers[0].getSource() + const tileGrid = source && source.getTileGrid() + if (!tileGrid) return false - const layer = terrainLayers[0] - const source = layer.getSource() if (source !== this.source_) this.tileCache_.clear() this.source_ = source - this.tileGrid_ = source.getTileGrid() + this.tileGrid_ = tileGrid this.tileUrlFunction_ = source.getTileUrlFunction() return true } +/** + * Run `attempt` (sync, returns true when it could do its work) now and + * again whenever terrain availability may have changed: a layer gets + * added, or a pending TileJSON source finishes loading its metadata. + */ +export const onTerrainReady = (map, attempt) => { + const tryNow = () => { + if (attempt()) return true + // a terrain layer may exist whose metadata is still loading + terrainLayers(map).forEach(layer => { + const source = layer.getSource() + if (source) source.once('change', tryNow) + }) + return false + } + if (tryNow()) return + const key = map.getLayers().on('add', () => { if (tryNow()) unByKey(key) }) +} + /** * Fixed zoom used for all analysis sampling: the coarsest zoom that still * resolves TARGET_ANALYSIS_RESOLUTION, clamped to the tile grid's range. @@ -147,6 +172,7 @@ ElevationService.prototype.sample_ = function (elevations, tileCoord, coordinate ElevationService.prototype.elevationAt = async function (coordinate) { if (!this.source_) return null const z = this.analysisZoom() + if (z === null) return null const tileCoord = this.tileGrid_.getTileCoordForCoordAndZ(coordinate, z) const elevations = await this.fetchTile_(...tileCoord) return elevations ? this.sample_(elevations, tileCoord, coordinate) : null @@ -165,6 +191,7 @@ ElevationService.prototype.profileAlongLine = async function (lineStringGeom, nu if (totalLength === 0 || numSamples < 2) return [] const z = this.analysisZoom() + if (z === null) return [] const samples = [] for (let i = 0; i < numSamples; i++) { const fraction = i / (numSamples - 1) @@ -203,6 +230,7 @@ ElevationService.prototype.getGrid = async function (extent) { if (!this.source_) return null let z = this.analysisZoom() + if (z === null) return null const minZ = this.tileGrid_.getMinZoom() const rangeFor = z => this.tileGrid_.getTileRangeForExtentAndZ(extent, z) let range = rangeFor(z) diff --git a/src/renderer/ol/interaction/area-of-sight/index.js b/src/renderer/ol/interaction/area-of-sight/index.js index 2bbc1574..c03c27c2 100644 --- a/src/renderer/ol/interaction/area-of-sight/index.js +++ b/src/renderer/ol/interaction/area-of-sight/index.js @@ -7,7 +7,7 @@ import { containsExtent } from 'ol/extent' import uuid from '../../../../shared/uuid' import { militaryFormat } from '../../../../shared/datetime' import * as ID from '../../../ids' -import { ElevationService } from '../../../model/ElevationService' +import { ElevationService, onTerrainReady } from '../../../model/ElevationService' import { ViewshedEngine, VISIBLE, HIDDEN, NO_DATA } from './engine' const ORIGINATOR_ID = uuid() @@ -209,14 +209,12 @@ export default ({ map, services }) => { rasterSource.changed() } - const tryInitialLoad = async () => { - if (!elevationService.setSource(map)) return false + const loadPersistedDocs = async () => { const tuples = await services.store.tuples(ID.AOS_SCOPE) for (const [id, doc] of tuples) { const existing = entries.get(id) if (!existing || docChanged(existing.doc, doc)) renderPersistedAos(id, doc) } - return true } ;(async () => { @@ -243,9 +241,10 @@ export default ({ map, services }) => { .filter(ID.isAosId) .forEach(id => hiddenIds.add(id)) - if (await tryInitialLoad()) return - const key = map.getLayers().on('add', async () => { - if (await tryInitialLoad()) unByKey(key) + onTerrainReady(map, () => { + if (!elevationService.setSource(map)) return false + loadPersistedDocs() + return true }) })() diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index c0a1c76a..8b70bacc 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -8,7 +8,7 @@ import { unByKey } from 'ol/Observable' import uuid from '../../../../shared/uuid' import { militaryFormat } from '../../../../shared/datetime' import * as ID from '../../../ids' -import { ElevationService } from '../../../model/ElevationService' +import { ElevationService, onTerrainReady } from '../../../model/ElevationService' import { setComputer } from '../../style/losCompute' import { computeLineOfSight, @@ -77,11 +77,7 @@ export default ({ map, services }) => { return true } - if (!tryEnableComputer()) { - const key = map.getLayers().on('add', () => { - if (tryEnableComputer()) unByKey(key) - }) - } + onTerrainReady(map, tryEnableComputer); // ──────────────────────────────────────────────────────────── // Migration: pre-pipeline docs {observer, target, heights} → GeoJSON diff --git a/test/renderer/model/ElevationService-test.js b/test/renderer/model/ElevationService-test.js index 51a3d49b..1566892b 100644 --- a/test/renderer/model/ElevationService-test.js +++ b/test/renderer/model/ElevationService-test.js @@ -30,6 +30,38 @@ const makeService = (fetched = []) => { } describe('ElevationService', function () { + describe('setSource', function () { + const mapWith = layer => ({ getLayerGroup: () => ({ getLayersArray: () => [layer] }) }) + const terrainLayer = source => ({ + get: key => (key === 'contentType' ? 'terrain/mapbox-rgb' : undefined), + getSource: () => source + }) + + it('returns false while the TileJSON source has no tile grid yet', function () { + const service = new ElevationService() + const pending = terrainLayer({ getTileGrid: () => null }) + assert.strictEqual(service.setSource(mapWith(pending)), false) + }) + + it('returns true once the tile grid is available', function () { + const service = new ElevationService() + const ready = terrainLayer({ + getTileGrid: () => makeTileGrid(), + getTileUrlFunction: () => () => undefined + }) + assert.strictEqual(service.setSource(mapWith(ready)), true) + }) + + it('queries never throw without a tile grid', async function () { + const service = new ElevationService() + service.source_ = {} // simulates the pre-fix partial state + service.tileGrid_ = null + assert.strictEqual(await service.getGrid([0, 0, 1000, 1000]), null) + assert.deepStrictEqual(await service.profileAlongLine(new LineString([[0, 0], [1000, 0]]), 10), []) + assert.strictEqual(await service.elevationAt([0, 0]), null) + }) + }) + describe('analysisZoom', function () { it('picks the coarsest zoom at or below the target resolution', function () { const service = makeService() From ad5489b92215a3e2fb14daab32245c2e8f721247 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 19:36:05 +0200 Subject: [PATCH 09/10] fix(analysis): settings hint no longer swallowed by async OSD clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emitter dispatches handlers via setImmediate, so the OSD clear from idle tools' command/draw/cancel resets landed after the freshly shown placement hint and erased it — the hint only reappeared after the first arrow-key press. - reset() clears the OSD only when the tool was actually active - the hint is re-asserted on pointer move (LoS placing phase, AoS preview) so it survives any remaining dispatch-order race --- .../ol/interaction/area-of-sight/index.js | 8 +++++++- .../ol/interaction/line-of-sight/index.js | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/renderer/ol/interaction/area-of-sight/index.js b/src/renderer/ol/interaction/area-of-sight/index.js index c03c27c2..f040a459 100644 --- a/src/renderer/ol/interaction/area-of-sight/index.js +++ b/src/renderer/ol/interaction/area-of-sight/index.js @@ -340,6 +340,9 @@ export default ({ map, services }) => { if (gen !== generation) return preview = rendered rasterSource.changed() + // re-assert the hint — a lost race with another tool's OSD clear + // right after start() would otherwise leave the cell empty + showOSD(`AoS: ${settingsInfo()} | click to place`) } const track = async (coordinate) => { @@ -366,6 +369,9 @@ export default ({ map, services }) => { } const reset = () => { + // The emitter dispatches asynchronously: an unconditional OSD clear + // from an idle tool would erase the hint another tool just showed. + const wasActive = mode !== 'idle' detachMapListeners() clearPreview() mode = 'idle' @@ -374,7 +380,7 @@ export default ({ map, services }) => { generation++ setCursor('') setSelectActive(true) - showOSD('') + if (wasActive) showOSD('') } const finalise = (coordinate) => { diff --git a/src/renderer/ol/interaction/line-of-sight/index.js b/src/renderer/ol/interaction/line-of-sight/index.js index 8b70bacc..548e1e34 100644 --- a/src/renderer/ol/interaction/line-of-sight/index.js +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -223,6 +223,9 @@ export default ({ map, services }) => { } const reset = () => { + // The emitter dispatches asynchronously: an unconditional OSD clear + // from an idle tool would erase the hint another tool just showed. + const wasActive = mode !== 'idle' detachMapListeners() clearInProgressOverlay() observer = null @@ -232,7 +235,7 @@ export default ({ map, services }) => { setSelectActive(true) // Invalidate any in-flight compute so its result will not be rendered. computeGeneration++ - showOSD('') + if (wasActive) showOSD('') } const finalise = async (coordinate) => { @@ -258,10 +261,15 @@ export default ({ map, services }) => { } const onPointerMove = (event) => { - if (mode !== 'tracking-target' || !observer) return if (event.dragging) return - lastTarget = event.coordinate - recompute(event.coordinate) + if (mode === 'placing-observer') { + // re-assert the hint — a lost race with another tool's OSD clear + // right after start() would otherwise leave the cell empty + showOSD(`LoS: click to place observer | ${heightsInfo()}`) + } else if (mode === 'tracking-target' && observer) { + lastTarget = event.coordinate + recompute(event.coordinate) + } } // Height adjustment and cancel while the tool is active. Capture phase From cc7c275e57ccc4ecacf91cf3d142b39faa325990 Mon Sep 17 00:00:00 2001 From: Thomas Halwax Date: Mon, 6 Jul 2026 19:41:40 +0200 Subject: [PATCH 10/10] feat(sidebar): list LoS/AoS under the measurements scope The measurements scope switch now covers '@measure @los @aos' (scope query tokens combine with OR). The active-state check handles multi-token switches: active when all of its tokens are part of the current search scope. --- src/renderer/components/sidebar/ScopeSwitcher.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/sidebar/ScopeSwitcher.js b/src/renderer/components/sidebar/ScopeSwitcher.js index 1389c885..a5eddce8 100644 --- a/src/renderer/components/sidebar/ScopeSwitcher.js +++ b/src/renderer/components/sidebar/ScopeSwitcher.js @@ -21,7 +21,7 @@ const SCOPES = { [`@${ID.PLACE}`]: 'mdiSearchWeb', [`@${ID.TILE_SERVICE}`]: 'mdiEarth', [`@${ID.SSE_SERVICE}`]: 'mdiAccessPointNetwork', - [`@${ID.MEASURE}`]: 'mdiAndroidStudio', + [`@${ID.MEASURE} @${ID.LOS} @${ID.AOS}`]: 'mdiAndroidStudio', [`@${ID.INVITED}`]: 'mdiCloudPlusOutline' } @@ -36,7 +36,7 @@ const TOOLTIPS = { [`@${ID.PLACE}`]: 'Search for addresses based on OSM (online only)', [`@${ID.TILE_SERVICE}`]: 'Manage existing tile services for maps', [`@${ID.SSE_SERVICE}`]: 'Manage live data sources', - [`@${ID.MEASURE}`]: 'Manage existing measurements', + [`@${ID.MEASURE} @${ID.LOS} @${ID.AOS}`]: 'Manage existing measurements and sight analyses', [`@${ID.INVITED}`]: 'Show invitations and join shared layers' } @@ -46,9 +46,12 @@ const TOOLTIPS = { const ScopeSwitch = props => { const [search, setSearch] = useMemento('ui.sidebar.search', defaultSearch) + // A switch may cover multiple scope tokens (e.g. '@measure @los @aos'); + // it is active when all of its tokens are part of the current search. + const activeTokens = search.history[0].scope.split(' ') const enabled = search.history.length > 1 ? false - : search.history[0].scope.split(' ').includes(props.scope) + : props.scope.split(' ').every(token => activeTokens.includes(token)) const className = props.name ? 'a74a-named'