Skip to content

Commit 084fe65

Browse files
authored
Line-of-Sight and Area-of-Sight terrain analysis (#122)
* 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. * 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. * 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. * 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) * 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 * feat(analysis): integrate LoS/AoS into the standard feature pipeline 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 * 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 * 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 * fix(analysis): settings hint no longer swallowed by async OSD clears 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 * 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.
1 parent 0611243 commit 084fe65

33 files changed

Lines changed: 2328 additions & 111 deletions

src/renderer/components/Toolbar.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ export const Toolbar = () => {
5252
commandRegistry.command('MEASURE_DISTANCE'),
5353
commandRegistry.command('MEASURE_AREA'),
5454
commandRegistry.command('MEASURE_CIRCLE'),
55-
commandRegistry.command('ELEVATION_PROFILE')
55+
commandRegistry.command('ELEVATION_PROFILE'),
56+
commandRegistry.command('LINE_OF_SIGHT'),
57+
commandRegistry.command('AREA_OF_SIGHT')
5658
]
5759

5860
const replicationCommands = [

src/renderer/components/map/Map.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import registerGraticules from './graticules'
1616
import measure from '../../ol/interaction/measure'
1717
import shapeInteraction from '../../ol/interaction/shape-interaction'
1818
import elevationProfile from '../../ol/interaction/elevation-profile'
19+
import lineOfSight from '../../ol/interaction/line-of-sight'
20+
import areaOfSight from '../../ol/interaction/area-of-sight'
1921
import print from '../print'
2022
import './Map.css'
2123
import './ScaleLine.css'
@@ -83,6 +85,8 @@ export const Map = () => {
8385
measure({ services, map })
8486
shapeInteraction({ services, map })
8587
elevationProfile({ services, map })
88+
lineOfSight({ services, map })
89+
areaOfSight({ services, map })
8690

8791
// Expose a function to query the current map resolution.
8892
services.getMapResolution = () => map.getView().getResolution()

src/renderer/components/map/eventHandlers.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ const mapHandlers = (services, map) => {
9595
map.once('rendercomplete', ({ target }) => sendPreview(services, target))
9696
map.on('pointermove', throttle(75, event => osdDriver.pointermove(event)))
9797

98-
// Deselect everything except features and markers.
98+
// Deselect everything except features, markers and analysis results.
9999
map.on('click', () => {
100-
const exclude = [ID.isFeatureId, ID.isMarkerId, ID.isMeasureId]
100+
const exclude = [ID.isFeatureId, ID.isMarkerId, ID.isMeasureId, ID.isLosId, ID.isAosId]
101101
const deselect = selection.selected(x => !exclude.some(p => p(x)))
102102
if (deselect.length) selection.deselect(deselect)
103103
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* eslint-disable react/prop-types */
2+
import React from 'react'
3+
import textProperty from './textProperty'
4+
import GridCols2 from './GridCols2'
5+
import ColSpan2 from './ColSpan2'
6+
import { DEFAULT_RADIUS_M, MAX_RADIUS_M } from '../../ol/interaction/area-of-sight'
7+
8+
const formatNumber = value => (typeof value === 'number' ? String(value) : '')
9+
10+
const setProperty = (key, valid) => value => feature => {
11+
const num = parseFloat(value)
12+
if (!valid(num)) return feature
13+
return { ...feature, properties: { ...feature.properties, [key]: num } }
14+
}
15+
16+
const Radius = textProperty({
17+
label: `Radius [m] (max ${MAX_RADIUS_M})`,
18+
get: feature => formatNumber(feature.properties?.radius ?? DEFAULT_RADIUS_M),
19+
set: value => feature => {
20+
const num = parseFloat(value)
21+
if (!Number.isFinite(num) || num < 100) return feature
22+
return {
23+
...feature,
24+
properties: { ...feature.properties, radius: Math.min(num, MAX_RADIUS_M) }
25+
}
26+
}
27+
})
28+
29+
const ObserverHeight = textProperty({
30+
label: 'Observer height [m]',
31+
get: feature => formatNumber(feature.properties?.observerHeight),
32+
set: setProperty('observerHeight', num => Number.isFinite(num) && num >= 0)
33+
})
34+
35+
const TargetHeight = textProperty({
36+
label: 'Target height [m]',
37+
get: feature => formatNumber(feature.properties?.targetHeight),
38+
set: setProperty('targetHeight', num => Number.isFinite(num) && num >= 0)
39+
})
40+
41+
const AreaOfSightProperties = (props) => (
42+
<GridCols2>
43+
<ObserverHeight {...props} />
44+
<TargetHeight {...props} />
45+
<ColSpan2>
46+
<Radius {...props} />
47+
</ColSpan2>
48+
</GridCols2>
49+
)
50+
51+
export default AreaOfSightProperties
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/* eslint-disable react/prop-types */
2+
import React from 'react'
3+
import { getLength } from 'ol/sphere'
4+
import LineString from 'ol/geom/LineString'
5+
import textProperty from './textProperty'
6+
import GridCols2 from './GridCols2'
7+
import ColSpan2 from './ColSpan2'
8+
9+
const formatHeight = h => (typeof h === 'number' ? h.toFixed(2) : '')
10+
11+
const setHeight = key => value => feature => {
12+
const num = parseFloat(value)
13+
if (!Number.isFinite(num) || num < 0) return feature
14+
return { ...feature, properties: { ...feature.properties, [key]: num } }
15+
}
16+
17+
const ObserverHeight = textProperty({
18+
label: 'Observer height [m]',
19+
get: feature => formatHeight(feature.properties?.observerHeight),
20+
set: setHeight('observerHeight')
21+
})
22+
23+
const TargetHeight = textProperty({
24+
label: 'Target height [m]',
25+
get: feature => formatHeight(feature.properties?.targetHeight),
26+
set: setHeight('targetHeight')
27+
})
28+
29+
const distanceKm = (doc) => {
30+
const coordinates = doc?.geometry?.type === 'LineString' && doc.geometry.coordinates
31+
if (!coordinates || coordinates.length < 2) return null
32+
return getLength(new LineString(coordinates)) / 1000
33+
}
34+
35+
const LineOfSightProperties = (props) => {
36+
const docs = Object.values(props.features)
37+
const single = docs.length === 1
38+
const km = single ? distanceKm(docs[0]) : null
39+
40+
return (
41+
<GridCols2>
42+
<ObserverHeight {...props} />
43+
<TargetHeight {...props} />
44+
{single && km !== null && (
45+
<ColSpan2>
46+
<div className='form-textfield'>
47+
<span className='form-textfield__label'>Distance</span>
48+
<span className='form-textfield__input' style={{ paddingTop: 8 }}>
49+
{km.toFixed(2)} km
50+
</span>
51+
</div>
52+
</ColSpan2>
53+
)}
54+
</GridCols2>
55+
)
56+
}
57+
58+
export default LineOfSightProperties

src/renderer/components/properties/Properties.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import SKKMUnitProperties from './SKKMUnitProperties'
2020
import SKKMCommandProperties from './SKKMCommandProperties'
2121
import ShapeProperties from './ShapeProperties'
2222
import TextShapeProperties from './TextShapeProperties'
23+
import LineOfSightProperties from './LineOfSightProperties'
24+
import AreaOfSightProperties from './AreaOfSightProperties'
2325
import './Properties.css'
2426

2527
const propertiesPanels = {
@@ -38,7 +40,9 @@ const propertiesPanels = {
3840
'sse-service': props => <SSEServiceProperties {...props}/>,
3941
'feature:SKKM/K': props => <SKKMStandardProperties {...props}/>,
4042
'feature:SKKM/KU': props => <SKKMUnitProperties {...props}/>,
41-
'feature:SKKM/KC': props => <SKKMCommandProperties {...props}/>
43+
'feature:SKKM/KC': props => <SKKMCommandProperties {...props}/>,
44+
los: props => <LineOfSightProperties {...props}/>,
45+
aos: props => <AreaOfSightProperties {...props}/>
4246
}
4347

4448
const singletons = ['tile-service', 'tile-layers', 'sse-service']

src/renderer/components/sidebar/ScopeSwitcher.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const SCOPES = {
2121
[`@${ID.PLACE}`]: 'mdiSearchWeb',
2222
[`@${ID.TILE_SERVICE}`]: 'mdiEarth',
2323
[`@${ID.SSE_SERVICE}`]: 'mdiAccessPointNetwork',
24-
[`@${ID.MEASURE}`]: 'mdiAndroidStudio',
24+
[`@${ID.MEASURE} @${ID.LOS} @${ID.AOS}`]: 'mdiAndroidStudio',
2525
[`@${ID.INVITED}`]: 'mdiCloudPlusOutline'
2626
}
2727

@@ -36,7 +36,7 @@ const TOOLTIPS = {
3636
[`@${ID.PLACE}`]: 'Search for addresses based on OSM (online only)',
3737
[`@${ID.TILE_SERVICE}`]: 'Manage existing tile services for maps',
3838
[`@${ID.SSE_SERVICE}`]: 'Manage live data sources',
39-
[`@${ID.MEASURE}`]: 'Manage existing measurements',
39+
[`@${ID.MEASURE} @${ID.LOS} @${ID.AOS}`]: 'Manage existing measurements and sight analyses',
4040
[`@${ID.INVITED}`]: 'Show invitations and join shared layers'
4141
}
4242

@@ -46,9 +46,12 @@ const TOOLTIPS = {
4646
const ScopeSwitch = props => {
4747
const [search, setSearch] = useMemento('ui.sidebar.search', defaultSearch)
4848

49+
// A switch may cover multiple scope tokens (e.g. '@measure @los @aos');
50+
// it is active when all of its tokens are part of the current search.
51+
const activeTokens = search.history[0].scope.split(' ')
4952
const enabled = search.history.length > 1
5053
? false
51-
: search.history[0].scope.split(' ').includes(props.scope)
54+
: props.scope.split(' ').every(token => activeTokens.includes(token))
5255

5356
const className = props.name
5457
? 'a74a-named'

src/renderer/ids.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const DEFAULT = 'default'
2929
export const TAGS = 'tags'
3030
export const STICKY = 'sticky'
3131
export const MEASURE = 'measure'
32+
export const LOS = 'los'
33+
export const AOS = 'aos'
3234
export const SHARED = 'shared'
3335
export const INVITED = 'invited'
3436

@@ -45,6 +47,8 @@ export const TILE_PRESET_SCOPE = TILE_PRESET + COLON
4547
export const TILE_LAYER_SCOPE = TILE_LAYER + COLON
4648
export const SSE_SERVICE_SCOPE = SSE_SERVICE + COLON
4749
export const MEASURE_SCOPE = MEASURE + COLON
50+
export const LOS_SCOPE = LOS + COLON
51+
export const AOS_SCOPE = AOS + COLON
4852

4953
export const LINK_PREFIX = 'link' + PLUS
5054
export const STYLE_PREFIX = 'style' + PLUS
@@ -106,6 +110,8 @@ export const isHiddenId = isId(HIDDEN_PREFIX)
106110
export const isDefaultId = isId(DEFAULT_PREFIX)
107111
export const isTagsId = isId(TAGS_PREFIX)
108112
export const isMeasureId = isId(MEASURE_SCOPE)
113+
export const isLosId = isId(LOS_SCOPE)
114+
export const isAosId = isId(AOS_SCOPE)
109115
export const isSharedLayerId = isId(sharedId(LAYER_SCOPE))
110116
export const isInvitedId = isId(INVITED)
111117
export const isRoleId = isId(ROLE_PREFIX)
@@ -173,6 +179,8 @@ export const tileLayerId = (tileServiceId, layerId) =>
173179
export const markerId = () => makeId(MARKER, uuid())
174180
export const bookmarkId = () => makeId(BOOKMARK, uuid())
175181
export const measureId = () => makeId(MEASURE, uuid())
182+
export const losId = () => makeId(LOS, uuid())
183+
export const aosId = () => makeId(AOS, uuid())
176184
export const linkId = id => LINK + PLUS + id + SLASH + uuid()
177185
export const invitationId = () => makeId(INVITED, uuid())
178186

src/renderer/model/CommandRegistry.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import creationCommand from './commands/CreationCommands'
77
import measureCommands from './commands/MeasureCommands'
88
import shapeCommands from './commands/ShapeCommands'
99
import elevationProfileCommands from './commands/ElevationProfileCommands'
10+
import lineOfSightCommands from './commands/LineOfSightCommands'
11+
import areaOfSightCommands from './commands/AreaOfSightCommands'
1012
import printCommands from './commands/PrintCommands'
1113
import replicationCommands from './commands/ReplicationCommands'
1214

@@ -23,6 +25,8 @@ export function CommandRegistry (services) {
2325
Object.assign(this, measureCommands(services))
2426
Object.assign(this, shapeCommands(services))
2527
Object.assign(this, elevationProfileCommands(services))
28+
Object.assign(this, lineOfSightCommands(services))
29+
Object.assign(this, areaOfSightCommands(services))
2630
Object.assign(this, printCommands(services))
2731
Object.assign(this, replicationCommands(services))
2832

0 commit comments

Comments
 (0)