diff --git a/src/renderer/components/Toolbar.js b/src/renderer/components/Toolbar.js index e2c005c0..8a119bb9 100644 --- a/src/renderer/components/Toolbar.js +++ b/src/renderer/components/Toolbar.js @@ -52,7 +52,9 @@ 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'), + commandRegistry.command('AREA_OF_SIGHT') ] const replicationCommands = [ diff --git a/src/renderer/components/map/Map.js b/src/renderer/components/map/Map.js index 4ba758e1..beac4323 100644 --- a/src/renderer/components/map/Map.js +++ b/src/renderer/components/map/Map.js @@ -16,6 +16,8 @@ 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 areaOfSight from '../../ol/interaction/area-of-sight' import print from '../print' import './Map.css' import './ScaleLine.css' @@ -83,6 +85,8 @@ export const Map = () => { measure({ services, 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..14a66b2a --- /dev/null +++ b/src/renderer/components/properties/AreaOfSightProperties.js @@ -0,0 +1,51 @@ +/* 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 setProperty = (key, valid) => value => feature => { + const num = parseFloat(value) + 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.properties?.radius ?? DEFAULT_RADIUS_M), + set: value => feature => { + const num = parseFloat(value) + if (!Number.isFinite(num) || num < 100) return feature + return { + ...feature, + properties: { ...feature.properties, radius: Math.min(num, MAX_RADIUS_M) } + } + } +}) + +const ObserverHeight = textProperty({ + label: 'Observer height [m]', + 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.properties?.targetHeight), + set: setProperty('targetHeight', num => Number.isFinite(num) && num >= 0) +}) + +const AreaOfSightProperties = (props) => ( + + + + + + + +) + +export default AreaOfSightProperties diff --git a/src/renderer/components/properties/LineOfSightProperties.js b/src/renderer/components/properties/LineOfSightProperties.js new file mode 100644 index 00000000..c112029a --- /dev/null +++ b/src/renderer/components/properties/LineOfSightProperties.js @@ -0,0 +1,58 @@ +/* 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, properties: { ...feature.properties, [key]: num } } +} + +const ObserverHeight = textProperty({ + label: 'Observer height [m]', + get: feature => formatHeight(feature.properties?.observerHeight), + set: setHeight('observerHeight') +}) + +const TargetHeight = textProperty({ + label: 'Target height [m]', + get: feature => formatHeight(feature.properties?.targetHeight), + set: setHeight('targetHeight') +}) + +const distanceKm = (doc) => { + 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) => { + 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..0ed5bddc 100644 --- a/src/renderer/components/properties/Properties.js +++ b/src/renderer/components/properties/Properties.js @@ -20,6 +20,8 @@ import SKKMUnitProperties from './SKKMUnitProperties' 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 = { @@ -38,7 +40,9 @@ const propertiesPanels = { 'sse-service': props => , 'feature:SKKM/K': props => , 'feature:SKKM/KU': props => , - 'feature:SKKM/KC': props => + 'feature:SKKM/KC': props => , + los: props => , + aos: props => } const singletons = ['tile-service', 'tile-layers', 'sse-service'] 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' diff --git a/src/renderer/ids.js b/src/renderer/ids.js index 815d4135..86fd1d59 100644 --- a/src/renderer/ids.js +++ b/src/renderer/ids.js @@ -29,6 +29,8 @@ export const DEFAULT = 'default' 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' @@ -45,6 +47,8 @@ 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 AOS_SCOPE = AOS + COLON export const LINK_PREFIX = 'link' + PLUS export const STYLE_PREFIX = 'style' + PLUS @@ -106,6 +110,8 @@ 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 isAosId = isId(AOS_SCOPE) export const isSharedLayerId = isId(sharedId(LAYER_SCOPE)) export const isInvitedId = isId(INVITED) export const isRoleId = isId(ROLE_PREFIX) @@ -173,6 +179,8 @@ 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 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 6b239f5f..b2bc5080 100644 --- a/src/renderer/model/CommandRegistry.js +++ b/src/renderer/model/CommandRegistry.js @@ -7,6 +7,8 @@ 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 areaOfSightCommands from './commands/AreaOfSightCommands' import printCommands from './commands/PrintCommands' import replicationCommands from './commands/ReplicationCommands' @@ -23,6 +25,8 @@ 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, areaOfSightCommands(services)) Object.assign(this, printCommands(services)) Object.assign(this, replicationCommands(services)) diff --git a/src/renderer/model/ElevationService.js b/src/renderer/model/ElevationService.js index ed03b919..6ff01a24 100644 --- a/src/renderer/model/ElevationService.js +++ b/src/renderer/model/ElevationService.js @@ -1,159 +1,279 @@ import { getLength } from 'ol/sphere' +import { unByKey } from 'ol/Observable' -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 } +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 } /** - * 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} + * 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. + * @returns {number|null} + */ +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 +} + +/** + * Cell size [projection units ≈ m] at the analysis zoom. + * @returns {number|null} */ -ElevationService.prototype.tileUrl_ = function (z, x, y) { - return this.tileUrlFunction_([z, x, y], 1, this.source_.getProjection()) +ElevationService.prototype.analysisResolution = function () { + const z = this.analysisZoom() + return z === null ? null : this.tileGrid_.getResolution(z) } /** - * Fetch a tile and return its ImageData (cached). - * @param {string} key - cache key "z/x/y" - * @param {string} url - tile URL - * @returns {Promise} + * Fetch and decode a tile (promise-cached, so concurrent requests for + * the same tile share one download). + * @returns {Promise} */ -ElevationService.prototype.fetchTile_ = async function (key, url) { - if (this.tileCache_.has(key)) return this.tileCache_.get(key) +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 + } - try { - const imageData = await new Promise((resolve, reject) => { + 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() + 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 } /** - * 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() + if (z === null) return [] + 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 }) + } + + 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() + if (z === null) return null + 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) - return results + 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/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/model/commands/LineOfSightCommands.js b/src/renderer/model/commands/LineOfSightCommands.js new file mode 100644 index 00000000..fb62e734 --- /dev/null +++ b/src/renderer/model/commands/LineOfSightCommands.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 LineOfSight = function (services) { + this.emitter = services.emitter + this.store = services.store + this.label = 'Line of Sight' + this.path = 'mdiEye' + 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(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/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/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..f040a459 --- /dev/null +++ b/src/renderer/ol/interaction/area-of-sight/index.js @@ -0,0 +1,473 @@ +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, onTerrainReady } from '../../../model/ElevationService' +import { ViewshedEngine, VISIBLE, HIDDEN, NO_DATA } from './engine' + +const ORIGINATOR_ID = uuid() + +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] + +/** + * 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() + + // ──────────────────────────────────────────────────────────── + // Raster overlay: one ImageCanvas source composites the live + // preview and all persisted viewsheds into the current view. + // ──────────────────────────────────────────────────────────── + + // aosId -> { doc, canvas, extent } + const entries = new Map() + const hiddenIds = new Set() + 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((entry, id) => { if (!hiddenIds.has(id)) draw(entry) }) + draw(preview) + return composite + } + + const rasterSource = new ImageCanvas({ canvasFunction, ratio: 1 }) + map.addLayer(new ImageLayer({ source: rasterSource })) + + /** + * 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 + doc properties. + */ + 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 + + 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: properties.observerHeight ?? DEFAULT_OBSERVER_HEIGHT_M, + targetHeight: properties.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 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.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 + + const rendered = await computeViewshed(observer, doc.properties ?? {}) + if (!rendered) return + entries.set(aosId, { doc, ...rendered }) + rasterSource.changed() + } + + 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) + } + } + + ;(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)) + + onTerrainReady(map, () => { + if (!elevationService.setSource(map)) return false + loadPersistedDocs() + return true + }) + })() + + 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)) { + 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 + // ──────────────────────────────────────────────────────────── + + /** @type {'idle' | 'tracking'} */ + let mode = 'idle' + let clickKey = null + let moveKey = null + 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, + 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 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) + + 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, liveProperties) + 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) => { + 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 = () => { + // 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' + pending = null + lastCoordinate = null + generation++ + setCursor('') + setSelectActive(true) + if (wasActive) showOSD('') + } + + const finalise = (coordinate) => { + const doc = { + type: 'Feature', + name: `AoS - ${militaryFormat.now()}`, + geometry: { type: 'Point', coordinates: coordinate }, + 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' + detachMapListeners() + setCursor('') + setSelectActive(true) + generation++ + clearPreview() + showOSD('') + finalise(event.coordinate) + } + + const start = async () => { + 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') + }) + // 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: ${settingsInfo()} | click to place`) + 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/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 new file mode 100644 index 00000000..30e59af4 --- /dev/null +++ b/src/renderer/ol/interaction/line-of-sight/compute.js @@ -0,0 +1,93 @@ +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 +}) => { + const { coordinate: clampedTarget, distance, clipped } = clampToMaxDistance(observer, target) + if (distance < 1) 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 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) + 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..548e1e34 --- /dev/null +++ b/src/renderer/ol/interaction/line-of-sight/index.js @@ -0,0 +1,341 @@ +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 { 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, onTerrainReady } from '../../../model/ElevationService' +import { setComputer } from '../../style/losCompute' +import { + computeLineOfSight, + DEFAULT_OBSERVER_HEIGHT_M, + DEFAULT_TARGET_HEIGHT_M +} from './compute' +import { + visibleSegmentStyle, + blockedSegmentStyle, + observerPointStyle, + blockerPointStyle, + clipMarkerStyle +} from './style' + +const ORIGINATOR_ID = uuid() + +/** + * 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 }) + map.addLayer(vector) + + 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 + let observerHeight = DEFAULT_OBSERVER_HEIGHT_M + let targetHeight = DEFAULT_TARGET_HEIGHT_M + let lastTarget = null + let computeGeneration = 0 + let clickKey = null + let moveKey = null + + const setCursor = (value) => { + const viewport = map.getViewport() + if (viewport) viewport.style.cursor = value + } + + 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. + // ──────────────────────────────────────────────────────────── + + const tryEnableComputer = () => { + if (!elevationService.setSource(map)) return false + setComputer(params => computeLineOfSight({ ...params, elevationService })) + return true + } + + onTerrainReady(map, tryEnableComputer); + + // ──────────────────────────────────────────────────────────── + // 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) => { + if (feature) source.removeFeature(feature) + } + + 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) => { + if (!observerFeature) { + observerFeature = new Feature(new Point(coord)) + observerFeature.setStyle(observerPointStyle) + source.addFeature(observerFeature) + } else { + observerFeature.getGeometry().setCoordinates(coord) + } + } + + 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} | ${heightsInfo()}`) + } + + const recompute = async (target) => { + const gen = ++computeGeneration + const result = await computeLineOfSight({ + observer, + target, + observerHeight, + targetHeight, + elevationService + }) + if (gen !== computeGeneration) return null + renderResult(result) + return result + } + + // ──────────────────────────────────────────────────────────── + // Tool lifecycle + // ──────────────────────────────────────────────────────────── + + const detachMapListeners = () => { + if (clickKey) { unByKey(clickKey); clickKey = null } + 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 = () => { + // 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 + lastTarget = null + mode = 'idle' + setCursor('') + setSelectActive(true) + // Invalidate any in-flight compute so its result will not be rendered. + computeGeneration++ + if (wasActive) showOSD('') + } + + const finalise = async (coordinate) => { + const result = await recompute(coordinate) + clearInProgressOverlay() + if (!result) return + + // Persist observer→clamped target; the feature pipeline renders it. + const doc = { + type: 'Feature', + name: `LoS - ${militaryFormat.now()}`, + geometry: { + type: 'LineString', + coordinates: [ + result.samples[0].coordinate, + result.samples[result.samples.length - 1].coordinate + ] + }, + properties: { observerHeight, targetHeight } + } + services.store.insert([[ID.losId(), doc]]) + services.sessionStore.put('tools.los', { observerHeight, targetHeight }) + } + + const onPointerMove = (event) => { + if (event.dragging) return + 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 + // 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 + 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() + setSelectActive(true) + await finalise(coordinate) + observer = null + showOSD('') + } + } + + 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 | ${heightsInfo()}`) + 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 +}) 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) + }) +}) diff --git a/test/renderer/model/ElevationService-test.js b/test/renderer/model/ElevationService-test.js new file mode 100644 index 00000000..1566892b --- /dev/null +++ b/test/renderer/model/ElevationService-test.js @@ -0,0 +1,143 @@ +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('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() + 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) + }) + }) +}) 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 }) + }) +})