@faclon-labs/app-sdk 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/dist/IOSenseApp.js +26 -6
- package/dist/SignIn.js +2 -2
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +35 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/map/globe.d.ts +5 -0
- package/dist/map/globe.js +5 -0
- package/dist/map/index.d.ts +21 -0
- package/dist/map/index.js +21 -0
- package/dist/map/source.d.ts +48 -0
- package/dist/map/source.js +73 -0
- package/dist/map/testing.d.ts +5 -0
- package/dist/map/testing.js +5 -0
- package/dist/session.d.ts +1 -1
- package/dist/session.js +1 -1
- package/package.json +55 -5
- package/vendor/SOURCE.txt +1 -0
- package/vendor/map-engine/cesium/camera.d.ts +10 -0
- package/vendor/map-engine/cesium/camera.js +25 -0
- package/vendor/map-engine/cesium/index.d.ts +2 -0
- package/vendor/map-engine/cesium/index.js +2 -0
- package/vendor/map-engine/cesium/renderer.d.ts +149 -0
- package/vendor/map-engine/cesium/renderer.js +837 -0
- package/vendor/map-engine/commands/index.d.ts +2 -0
- package/vendor/map-engine/commands/index.js +2 -0
- package/vendor/map-engine/commands/post-message.d.ts +32 -0
- package/vendor/map-engine/commands/post-message.js +77 -0
- package/vendor/map-engine/commands/webmcp.d.ts +17 -0
- package/vendor/map-engine/commands/webmcp.js +59 -0
- package/vendor/map-engine/core/commands.d.ts +82 -0
- package/vendor/map-engine/core/commands.js +46 -0
- package/vendor/map-engine/core/emitter.d.ts +9 -0
- package/vendor/map-engine/core/emitter.js +38 -0
- package/vendor/map-engine/core/equal.d.ts +2 -0
- package/vendor/map-engine/core/equal.js +28 -0
- package/vendor/map-engine/core/errors.d.ts +20 -0
- package/vendor/map-engine/core/errors.js +34 -0
- package/vendor/map-engine/core/geometry-family.d.ts +3 -0
- package/vendor/map-engine/core/geometry-family.js +13 -0
- package/vendor/map-engine/core/index.d.ts +4 -0
- package/vendor/map-engine/core/index.js +3 -0
- package/vendor/map-engine/core/types.d.ts +709 -0
- package/vendor/map-engine/core/types.js +7 -0
- package/vendor/map-engine/data/annotations.d.ts +3 -0
- package/vendor/map-engine/data/annotations.js +63 -0
- package/vendor/map-engine/data/contracts.d.ts +172 -0
- package/vendor/map-engine/data/contracts.js +12 -0
- package/vendor/map-engine/data/index.d.ts +4 -0
- package/vendor/map-engine/data/index.js +3 -0
- package/vendor/map-engine/data/schema.d.ts +7 -0
- package/vendor/map-engine/data/schema.js +40 -0
- package/vendor/map-engine/geofence/evaluator.d.ts +35 -0
- package/vendor/map-engine/geofence/evaluator.js +172 -0
- package/vendor/map-engine/geofence/history.d.ts +9 -0
- package/vendor/map-engine/geofence/history.js +19 -0
- package/vendor/map-engine/geofence/index.d.ts +2 -0
- package/vendor/map-engine/geofence/index.js +2 -0
- package/vendor/map-engine/index.d.ts +27 -0
- package/vendor/map-engine/index.js +14 -0
- package/vendor/map-engine/layers/index.d.ts +1 -0
- package/vendor/map-engine/layers/index.js +1 -0
- package/vendor/map-engine/layers/layers.d.ts +49 -0
- package/vendor/map-engine/layers/layers.js +316 -0
- package/vendor/map-engine/maplibre/deck-host.d.ts +105 -0
- package/vendor/map-engine/maplibre/deck-host.js +401 -0
- package/vendor/map-engine/maplibre/icons.d.ts +17 -0
- package/vendor/map-engine/maplibre/icons.js +65 -0
- package/vendor/map-engine/maplibre/index.d.ts +3 -0
- package/vendor/map-engine/maplibre/index.js +2 -0
- package/vendor/map-engine/maplibre/map-like.d.ts +56 -0
- package/vendor/map-engine/maplibre/map-like.js +1 -0
- package/vendor/map-engine/maplibre/renderer.d.ts +173 -0
- package/vendor/map-engine/maplibre/renderer.js +889 -0
- package/vendor/map-engine/maplibre/style.d.ts +84 -0
- package/vendor/map-engine/maplibre/style.js +493 -0
- package/vendor/map-engine/present/decorations.d.ts +25 -0
- package/vendor/map-engine/present/decorations.js +115 -0
- package/vendor/map-engine/present/index.d.ts +2 -0
- package/vendor/map-engine/present/index.js +2 -0
- package/vendor/map-engine/present/present.d.ts +47 -0
- package/vendor/map-engine/present/present.js +149 -0
- package/vendor/map-engine/query/index.d.ts +1 -0
- package/vendor/map-engine/query/index.js +1 -0
- package/vendor/map-engine/query/planner.d.ts +26 -0
- package/vendor/map-engine/query/planner.js +47 -0
- package/vendor/map-engine/react/MapView.d.ts +87 -0
- package/vendor/map-engine/react/MapView.js +270 -0
- package/vendor/map-engine/react/context.d.ts +39 -0
- package/vendor/map-engine/react/context.js +94 -0
- package/vendor/map-engine/react/index.d.ts +2 -0
- package/vendor/map-engine/react/index.js +2 -0
- package/vendor/map-engine/react/store.d.ts +19 -0
- package/vendor/map-engine/react/store.js +18 -0
- package/vendor/map-engine/realtime/apply.d.ts +74 -0
- package/vendor/map-engine/realtime/apply.js +155 -0
- package/vendor/map-engine/realtime/index.d.ts +1 -0
- package/vendor/map-engine/realtime/index.js +1 -0
- package/vendor/map-engine/render/index.d.ts +1 -0
- package/vendor/map-engine/render/index.js +1 -0
- package/vendor/map-engine/render/offline.d.ts +8 -0
- package/vendor/map-engine/render/offline.js +47 -0
- package/vendor/map-engine/render/renderer.d.ts +74 -0
- package/vendor/map-engine/render/renderer.js +1 -0
- package/vendor/map-engine/render/terrarium.d.ts +48 -0
- package/vendor/map-engine/render/terrarium.js +110 -0
- package/vendor/map-engine/runtime/drawing.d.ts +28 -0
- package/vendor/map-engine/runtime/drawing.js +58 -0
- package/vendor/map-engine/runtime/index.d.ts +5 -0
- package/vendor/map-engine/runtime/index.js +2 -0
- package/vendor/map-engine/runtime/offline.d.ts +24 -0
- package/vendor/map-engine/runtime/offline.js +37 -0
- package/vendor/map-engine/runtime/overlays.d.ts +25 -0
- package/vendor/map-engine/runtime/overlays.js +65 -0
- package/vendor/map-engine/runtime/runtime.d.ts +4 -0
- package/vendor/map-engine/runtime/runtime.js +2105 -0
- package/vendor/map-engine/runtime/types.d.ts +407 -0
- package/vendor/map-engine/runtime/types.js +1 -0
- package/vendor/map-engine/scene/index.d.ts +1 -0
- package/vendor/map-engine/scene/index.js +1 -0
- package/vendor/map-engine/scene/scene.d.ts +31 -0
- package/vendor/map-engine/scene/scene.js +216 -0
- package/vendor/map-engine/selection/index.d.ts +1 -0
- package/vendor/map-engine/selection/index.js +1 -0
- package/vendor/map-engine/selection/selection.d.ts +12 -0
- package/vendor/map-engine/selection/selection.js +28 -0
- package/vendor/map-engine/spatial/geojson.d.ts +15 -0
- package/vendor/map-engine/spatial/geojson.js +83 -0
- package/vendor/map-engine/spatial/geometry.d.ts +13 -0
- package/vendor/map-engine/spatial/geometry.js +206 -0
- package/vendor/map-engine/spatial/import.d.ts +27 -0
- package/vendor/map-engine/spatial/import.js +96 -0
- package/vendor/map-engine/spatial/index.d.ts +38 -0
- package/vendor/map-engine/spatial/index.js +37 -0
- package/vendor/map-engine/spatial/line-of-sight.d.ts +35 -0
- package/vendor/map-engine/spatial/line-of-sight.js +43 -0
- package/vendor/map-engine/spatial/measure.d.ts +28 -0
- package/vendor/map-engine/spatial/measure.js +90 -0
- package/vendor/map-engine/spatial/operations.d.ts +21 -0
- package/vendor/map-engine/spatial/operations.js +171 -0
- package/vendor/map-engine/spatial/query.d.ts +17 -0
- package/vendor/map-engine/spatial/query.js +88 -0
- package/vendor/map-engine/spatial/spatial-index.d.ts +24 -0
- package/vendor/map-engine/spatial/spatial-index.js +36 -0
- package/vendor/map-engine/spatial/volume-mesh.d.ts +19 -0
- package/vendor/map-engine/spatial/volume-mesh.js +56 -0
- package/vendor/map-engine/temporal/index.d.ts +3 -0
- package/vendor/map-engine/temporal/index.js +3 -0
- package/vendor/map-engine/temporal/journal.d.ts +20 -0
- package/vendor/map-engine/temporal/journal.js +34 -0
- package/vendor/map-engine/temporal/playback.d.ts +41 -0
- package/vendor/map-engine/temporal/playback.js +80 -0
- package/vendor/map-engine/temporal/tracks.d.ts +33 -0
- package/vendor/map-engine/temporal/tracks.js +119 -0
- package/vendor/map-engine/testing/annotation-store.d.ts +24 -0
- package/vendor/map-engine/testing/annotation-store.js +43 -0
- package/vendor/map-engine/testing/contracts.d.ts +41 -0
- package/vendor/map-engine/testing/contracts.js +328 -0
- package/vendor/map-engine/testing/fixtures-monitoring.d.ts +22 -0
- package/vendor/map-engine/testing/fixtures-monitoring.js +103 -0
- package/vendor/map-engine/testing/fixtures.d.ts +18 -0
- package/vendor/map-engine/testing/fixtures.js +28 -0
- package/vendor/map-engine/testing/index.d.ts +7 -0
- package/vendor/map-engine/testing/index.js +7 -0
- package/vendor/map-engine/testing/mock-ontology.d.ts +13 -0
- package/vendor/map-engine/testing/mock-ontology.js +24 -0
- package/vendor/map-engine/testing/mock-provider.d.ts +97 -0
- package/vendor/map-engine/testing/mock-provider.js +369 -0
- package/vendor/map-engine/testing/test-renderer.d.ts +72 -0
- package/vendor/map-engine/testing/test-renderer.js +153 -0
- package/vendor/map-engine/viewport/index.d.ts +1 -0
- package/vendor/map-engine/viewport/index.js +1 -0
- package/vendor/map-engine/viewport/viewport.d.ts +19 -0
- package/vendor/map-engine/viewport/viewport.js +102 -0
- package/vendor/map-provider-uns/client.d.ts +75 -0
- package/vendor/map-provider-uns/client.js +111 -0
- package/vendor/map-provider-uns/index.d.ts +2 -0
- package/vendor/map-provider-uns/index.js +2 -0
- package/vendor/map-provider-uns/provider.d.ts +131 -0
- package/vendor/map-provider-uns/provider.js +561 -0
|
@@ -0,0 +1,2105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MapRuntime: the orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* config → layers → (viewport bounds) → provider.query → validate → index → scene → renderer
|
|
5
|
+
* renderer events → viewport / selection / hover / map clicks → patches → renderer
|
|
6
|
+
*
|
|
7
|
+
* It does not know any database (only provider contracts), any renderer technology (only the Renderer
|
|
8
|
+
* contract), or any domain. It is not a security boundary: providers return authorized data.
|
|
9
|
+
*/
|
|
10
|
+
import { Emitter } from '../core/emitter.js';
|
|
11
|
+
import { deepEqual } from '../core/equal.js';
|
|
12
|
+
import { MapEngineError, isAbortError } from '../core/errors.js';
|
|
13
|
+
import { commandProblem } from '../core/commands.js';
|
|
14
|
+
import { isAuthAware, isMissingCapability } from '../data/contracts.js';
|
|
15
|
+
import { ageOf, effectiveLayer, nextAgeChangeMs, presentObject } from '../present/present.js';
|
|
16
|
+
import { decorationLayers, decorationObjects, decorationOwner } from '../present/decorations.js';
|
|
17
|
+
import { measure as measureGeometry } from '../spatial/measure.js';
|
|
18
|
+
import { labelPoint } from '../spatial/operations.js';
|
|
19
|
+
import { validateConditions } from '../spatial/query.js';
|
|
20
|
+
import { groupJournal, journalStateAt } from '../temporal/journal.js';
|
|
21
|
+
import { ANNOTATION_LAYERS, HIGHLIGHT_COLOR, PROPOSAL_LAYERS, SELECTABLE_OVERLAYS, annotationObjects, highlightLayers, highlightObjects, proposalObjects } from './overlays.js';
|
|
22
|
+
import { allowedOrigins, offlineViolations } from './offline.js';
|
|
23
|
+
import { normalizeTrack, stateAt, toIso, toMs, trailBetween, validateTrack } from '../temporal/tracks.js';
|
|
24
|
+
import { GeofenceEvaluator, readingsFrom, validateGeofence, watches } from '../geofence/evaluator.js';
|
|
25
|
+
import { evaluateTracks } from '../geofence/history.js';
|
|
26
|
+
import { autoFinishes, draftObjects, DRAFT_LAYERS, drawnGeometry, minPoints } from './drawing.js';
|
|
27
|
+
import { within } from '../spatial/operations.js';
|
|
28
|
+
import { geometryFamily } from '../core/geometry-family.js';
|
|
29
|
+
import { DERIVED_TYPES, GEOMETRY_FOR, LayerRegistry, layerNeeds, layerQuery, SOURCE_GEOMETRY_FOR } from '../layers/layers.js';
|
|
30
|
+
import { SceneManager, isEmptyPatch, mergePatches } from '../scene/scene.js';
|
|
31
|
+
import { SelectionState } from '../selection/selection.js';
|
|
32
|
+
import { RBushSpatialIndex } from '../spatial/spatial-index.js';
|
|
33
|
+
import { geometryBounds, unionBounds, validateGeometry, validatePosition } from '../spatial/geometry.js';
|
|
34
|
+
import { judgeSight, pathSamples } from '../spatial/line-of-sight.js';
|
|
35
|
+
import { matchesQuery, rankByRelation } from '../spatial/query.js';
|
|
36
|
+
import { computeBounds, normalizeViewport, validateViewport, zoomToFit } from '../viewport/viewport.js';
|
|
37
|
+
import { planQueries } from '../query/planner.js';
|
|
38
|
+
import { applyFrame, growTrails, patchIndex } from '../realtime/apply.js';
|
|
39
|
+
const ANNOTATION_KINDS = ['point', 'line', 'polygon', 'rectangle', 'circle', 'measurement', 'label', 'note'];
|
|
40
|
+
const isDerived = (l) => !!l && DERIVED_TYPES.includes(l.visualization.type);
|
|
41
|
+
/** A provider refused this user (doc 28 G13): an ACCESS_DENIED engine error, a `code` of the same name, or HTTP 403. */
|
|
42
|
+
function isDenied(e) {
|
|
43
|
+
const x = e;
|
|
44
|
+
return !!x && (x.code === 'ACCESS_DENIED' || x.status === 403);
|
|
45
|
+
}
|
|
46
|
+
/** Where a link meets an object: the point itself, or a point on a line or inside an area. */
|
|
47
|
+
const endPosition = (o) => (o.geometry.type === 'Point' ? o.geometry.coordinates : labelPoint(o.geometry));
|
|
48
|
+
const DEFAULT_SIZE = { width: 1024, height: 768 };
|
|
49
|
+
/** Which parts of a layer changed, as dotted paths an audit log can show (source.filter, visualization, visible…). */
|
|
50
|
+
function changedFields(before, after) {
|
|
51
|
+
if (!before)
|
|
52
|
+
return [];
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const key of ['visible', 'zIndex', 'visualization', 'interaction'])
|
|
55
|
+
if (!deepEqual(before[key], after[key]))
|
|
56
|
+
out.push(key);
|
|
57
|
+
for (const key of new Set([...Object.keys(before.source), ...Object.keys(after.source)])) {
|
|
58
|
+
if (!deepEqual(before.source[key], after.source[key]))
|
|
59
|
+
out.push(`source.${key}`);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Whether a drawing can finish now, and if not because the shape is invalid, why. */
|
|
64
|
+
function drawingCheck(kind, points) {
|
|
65
|
+
if (points.length < minPoints(kind))
|
|
66
|
+
return { canFinish: false };
|
|
67
|
+
const geometry = drawnGeometry(kind, points);
|
|
68
|
+
if (!geometry)
|
|
69
|
+
return { canFinish: false };
|
|
70
|
+
const check = validateGeometry(geometry);
|
|
71
|
+
return check.valid ? { canFinish: true } : { canFinish: false, problem: check.reason };
|
|
72
|
+
}
|
|
73
|
+
const FILTER_FIELDS = ['filter', 'where', 'spatialQuery', 'objectIds'];
|
|
74
|
+
function filterOf(source) {
|
|
75
|
+
const out = {};
|
|
76
|
+
for (const f of FILTER_FIELDS)
|
|
77
|
+
if (source[f] !== undefined)
|
|
78
|
+
out[f] = structuredClone(source[f]);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
class Runtime {
|
|
82
|
+
deps;
|
|
83
|
+
emitter = new Emitter();
|
|
84
|
+
layers;
|
|
85
|
+
scene;
|
|
86
|
+
selection;
|
|
87
|
+
index;
|
|
88
|
+
/** Last validated result per layer, so style or visualization changes re-filter without a new query. */
|
|
89
|
+
layerData = new Map();
|
|
90
|
+
viewport;
|
|
91
|
+
current = 'idle';
|
|
92
|
+
mounted = false;
|
|
93
|
+
/** A mount() is waiting for the renderer. */
|
|
94
|
+
mounting = false;
|
|
95
|
+
/** Bumped by unmount()/destroy(): a mount() that started before it is stale and must not finish. */
|
|
96
|
+
mountGen = 0;
|
|
97
|
+
container = null;
|
|
98
|
+
basemap;
|
|
99
|
+
renderer;
|
|
100
|
+
projection;
|
|
101
|
+
terrain;
|
|
102
|
+
tilesets;
|
|
103
|
+
/** Missing capabilities already reported for the current renderer ("layerId:capability" or "map:capability"). */
|
|
104
|
+
reportedNeeds = new Set();
|
|
105
|
+
rendererSubs = [];
|
|
106
|
+
selectable = true;
|
|
107
|
+
timer = null;
|
|
108
|
+
inflight = null;
|
|
109
|
+
controller = null;
|
|
110
|
+
debounceMs;
|
|
111
|
+
fallbackSize;
|
|
112
|
+
frame;
|
|
113
|
+
reconnectMs;
|
|
114
|
+
skewToleranceMs;
|
|
115
|
+
/** Objects already reported for clock skew (one warning each). */
|
|
116
|
+
skewed = new Set();
|
|
117
|
+
bindings = new Map();
|
|
118
|
+
subscriptions = [];
|
|
119
|
+
pending = [];
|
|
120
|
+
time = { mode: 'live' };
|
|
121
|
+
/** Track layers: trail data per layer and object. */
|
|
122
|
+
trailData = new Map();
|
|
123
|
+
/** Replay: tracks per point layer and object, for the whole replay range. */
|
|
124
|
+
replayTracks = new Map();
|
|
125
|
+
/** What the scene shows per layer (live data, or replay state at T). The client index follows it. */
|
|
126
|
+
displayed = new Map();
|
|
127
|
+
replayQueued = false;
|
|
128
|
+
warnedNoHistory = false;
|
|
129
|
+
now;
|
|
130
|
+
geofence = new GeofenceEvaluator();
|
|
131
|
+
drawing = null;
|
|
132
|
+
geofenceLog = [];
|
|
133
|
+
replayEvents = [];
|
|
134
|
+
frameQueued = false;
|
|
135
|
+
reconnectTimer = null;
|
|
136
|
+
/** Link layers: the links last loaded per layer, drawn between their ends' current positions (doc 28 G4). */
|
|
137
|
+
linkData = new Map();
|
|
138
|
+
/** Replay: journal entries per layer, by object, to rebuild whole objects at a time (doc 29 §8). */
|
|
139
|
+
journalData = new Map();
|
|
140
|
+
/** Fires when the next object changes age state or enters/leaves its validity window (doc 28 G1). */
|
|
141
|
+
ageTimer = null;
|
|
142
|
+
ageSoon = null;
|
|
143
|
+
layerStatus = new Map();
|
|
144
|
+
auth;
|
|
145
|
+
highlighted = null;
|
|
146
|
+
highlightTimer = null;
|
|
147
|
+
proposals = new Map();
|
|
148
|
+
annotationStore = new Map();
|
|
149
|
+
annotationSub = null;
|
|
150
|
+
annotationsVisible = true;
|
|
151
|
+
/** One-time warnings already given (by key). */
|
|
152
|
+
warned = new Set();
|
|
153
|
+
offline;
|
|
154
|
+
idSeq = 0;
|
|
155
|
+
stats = {
|
|
156
|
+
loads: 0,
|
|
157
|
+
providerCalls: 0,
|
|
158
|
+
lastLoadMs: 0,
|
|
159
|
+
lastPlan: null,
|
|
160
|
+
realtime: { subscriptions: 0, received: 0, applied: 0, stale: 0, outOfView: 0, frames: 0, reconnects: 0 },
|
|
161
|
+
temporal: { trackFetches: 0, tracksLoaded: 0, replayFrames: 0, trailPointsAppended: 0 },
|
|
162
|
+
geofence: { geofences: 0, liveEvents: 0, replayEvents: 0 },
|
|
163
|
+
};
|
|
164
|
+
constructor(config, deps, options = {}) {
|
|
165
|
+
this.deps = deps;
|
|
166
|
+
const v = validateViewport(config.viewport);
|
|
167
|
+
if (!v.valid)
|
|
168
|
+
throw new MapEngineError('INVALID_VIEWPORT', v.reason);
|
|
169
|
+
this.viewport = normalizeViewport(config.viewport);
|
|
170
|
+
this.basemap = structuredClone(config.basemap);
|
|
171
|
+
this.renderer = deps.renderer;
|
|
172
|
+
this.projection = config.projection ?? 'mercator';
|
|
173
|
+
if (config.projection !== undefined && config.projection !== 'mercator' && config.projection !== 'globe')
|
|
174
|
+
throw new MapEngineError('INVALID_LAYER', `projection must be mercator or globe`);
|
|
175
|
+
if (config.terrain) {
|
|
176
|
+
const t = validateTerrain(config.terrain);
|
|
177
|
+
if (!t.valid)
|
|
178
|
+
throw new MapEngineError('INVALID_LAYER', t.reason);
|
|
179
|
+
}
|
|
180
|
+
this.terrain = config.terrain ? structuredClone(config.terrain) : null;
|
|
181
|
+
const ts = validateTilesets(config.tilesets ?? []);
|
|
182
|
+
if (!ts.valid)
|
|
183
|
+
throw new MapEngineError('INVALID_LAYER', ts.reason);
|
|
184
|
+
this.tilesets = structuredClone(config.tilesets ?? []);
|
|
185
|
+
this.offline = config.offline;
|
|
186
|
+
this.assertOffline({ basemap: config.basemap, terrain: config.terrain ?? null, tilesets: config.tilesets ?? [], layers: config.layers });
|
|
187
|
+
this.auth = deps.auth ? structuredClone(deps.auth) : null;
|
|
188
|
+
this.handAuth();
|
|
189
|
+
this.debounceMs = options.debounceMs ?? 150;
|
|
190
|
+
this.fallbackSize = options.fallbackSize ?? DEFAULT_SIZE;
|
|
191
|
+
this.layers = new LayerRegistry(config.layers);
|
|
192
|
+
this.scene = new SceneManager(this.viewport);
|
|
193
|
+
this.scene.setLayers(this.sceneLayers());
|
|
194
|
+
this.selection = new SelectionState(options.now);
|
|
195
|
+
this.index = deps.spatialIndex ?? new RBushSpatialIndex();
|
|
196
|
+
this.frame =
|
|
197
|
+
options.frame ?? (typeof requestAnimationFrame === 'function' ? (cb) => void requestAnimationFrame(() => cb()) : (cb) => void setTimeout(cb, 16));
|
|
198
|
+
this.reconnectMs = options.reconnectMs ?? 1000;
|
|
199
|
+
this.skewToleranceMs = options.clockSkewToleranceMs ?? 60_000;
|
|
200
|
+
this.now = options.now ?? Date.now;
|
|
201
|
+
}
|
|
202
|
+
get state() {
|
|
203
|
+
return this.current;
|
|
204
|
+
}
|
|
205
|
+
on(event, listener) {
|
|
206
|
+
return this.emitter.on(event, listener);
|
|
207
|
+
}
|
|
208
|
+
async mount(container) {
|
|
209
|
+
this.assertAlive();
|
|
210
|
+
if (this.mounted || this.mounting)
|
|
211
|
+
throw new MapEngineError('ALREADY_MOUNTED', 'runtime is already mounted; unmount or destroy it first');
|
|
212
|
+
const r = this.renderer;
|
|
213
|
+
const gen = ++this.mountGen;
|
|
214
|
+
this.mounting = true;
|
|
215
|
+
this.container = container;
|
|
216
|
+
try {
|
|
217
|
+
const offline = allowedOrigins(this.offline);
|
|
218
|
+
await r.initialize(container, { basemap: this.basemap, viewport: this.viewport, projection: this.projection, ...(this.terrain ? { terrain: this.terrain } : {}), ...(this.tilesets.length ? { tilesets: this.tilesets } : {}), ...(offline ? { offline: { allowedOrigins: offline } } : {}) });
|
|
219
|
+
}
|
|
220
|
+
catch (cause) {
|
|
221
|
+
if (gen !== this.mountGen)
|
|
222
|
+
return; // unmounted or destroyed meanwhile: nothing to report
|
|
223
|
+
this.mounting = false;
|
|
224
|
+
throw new MapEngineError('RENDERER_ERROR', 'renderer failed to initialize', undefined, { cause });
|
|
225
|
+
}
|
|
226
|
+
if (gen !== this.mountGen)
|
|
227
|
+
return; // unmount()/destroy() already released the renderer
|
|
228
|
+
this.mounting = false;
|
|
229
|
+
if (this.current === 'destroyed') {
|
|
230
|
+
r.destroy();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.mounted = true;
|
|
234
|
+
this.checkNeeds();
|
|
235
|
+
this.rendererSubs = [
|
|
236
|
+
r.onViewportChange((v) => this.onRendererViewport(v)),
|
|
237
|
+
r.onPick((e) => this.onPick(e)),
|
|
238
|
+
r.onMapClick((e) => {
|
|
239
|
+
if (this.drawing)
|
|
240
|
+
return this.addDrawingPoint(e.position);
|
|
241
|
+
this.emitter.emit('mapClick', e);
|
|
242
|
+
this.emitter.emit('click', { position: e.position, hit: null });
|
|
243
|
+
}),
|
|
244
|
+
r.onHover((e) => this.onHover(e)),
|
|
245
|
+
...(r.onCameraMove ? [r.onCameraMove(() => this.emitter.emit('move', null))] : []),
|
|
246
|
+
r.onError((e) => this.emitter.emit('error', new MapEngineError('RENDERER_ERROR', e.message, undefined, { cause: e }))),
|
|
247
|
+
];
|
|
248
|
+
this.scene.setViewport(this.withBounds(this.viewport));
|
|
249
|
+
r.render(this.scene.getState());
|
|
250
|
+
this.emitter.emit('viewport', this.getViewport()); // first camera, so readouts don't start empty
|
|
251
|
+
void this.loadAnnotations();
|
|
252
|
+
await this.refresh();
|
|
253
|
+
}
|
|
254
|
+
unmount() {
|
|
255
|
+
if (this.current === 'destroyed' || (!this.mounted && !this.mounting))
|
|
256
|
+
return;
|
|
257
|
+
this.release();
|
|
258
|
+
this.setState('idle');
|
|
259
|
+
}
|
|
260
|
+
isMounted() {
|
|
261
|
+
return this.mounted;
|
|
262
|
+
}
|
|
263
|
+
async setBasemap(basemap) {
|
|
264
|
+
this.assertAlive();
|
|
265
|
+
if (deepEqual(basemap, this.basemap))
|
|
266
|
+
return;
|
|
267
|
+
this.assertOffline({ basemap });
|
|
268
|
+
this.basemap = structuredClone(basemap);
|
|
269
|
+
if (this.mounting) {
|
|
270
|
+
// The renderer is starting with the old basemap: start it again with the new one.
|
|
271
|
+
const container = this.container;
|
|
272
|
+
this.unmount();
|
|
273
|
+
return this.mount(container);
|
|
274
|
+
}
|
|
275
|
+
if (!this.mounted)
|
|
276
|
+
return; // the next mount uses it
|
|
277
|
+
const r = this.renderer;
|
|
278
|
+
const gen = this.mountGen;
|
|
279
|
+
if (r.setBasemap) {
|
|
280
|
+
try {
|
|
281
|
+
await r.setBasemap(basemap);
|
|
282
|
+
}
|
|
283
|
+
catch (cause) {
|
|
284
|
+
if (gen !== this.mountGen)
|
|
285
|
+
return;
|
|
286
|
+
throw new MapEngineError('RENDERER_ERROR', 'basemap could not be changed', undefined, { cause });
|
|
287
|
+
}
|
|
288
|
+
if (gen !== this.mountGen || !this.mounted)
|
|
289
|
+
return;
|
|
290
|
+
r.render(this.scene.getState());
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const container = this.container;
|
|
294
|
+
this.unmount();
|
|
295
|
+
await this.mount(container);
|
|
296
|
+
}
|
|
297
|
+
async setRenderer(renderer) {
|
|
298
|
+
this.assertAlive();
|
|
299
|
+
if (renderer === this.renderer)
|
|
300
|
+
return;
|
|
301
|
+
const container = this.container;
|
|
302
|
+
const wasShown = this.mounted || this.mounting;
|
|
303
|
+
if (wasShown)
|
|
304
|
+
this.unmount();
|
|
305
|
+
this.renderer = renderer;
|
|
306
|
+
this.reportedNeeds.clear();
|
|
307
|
+
if (wasShown && container)
|
|
308
|
+
await this.mount(container);
|
|
309
|
+
}
|
|
310
|
+
setProjection(projection) {
|
|
311
|
+
this.assertAlive();
|
|
312
|
+
if (projection !== 'mercator' && projection !== 'globe')
|
|
313
|
+
throw new MapEngineError('INVALID_LAYER', `projection must be mercator or globe`);
|
|
314
|
+
if (projection === this.projection)
|
|
315
|
+
return;
|
|
316
|
+
this.projection = projection;
|
|
317
|
+
if (this.mounted) {
|
|
318
|
+
this.renderer.setProjection?.(projection);
|
|
319
|
+
this.checkNeeds();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
setTerrain(terrain) {
|
|
323
|
+
this.assertAlive();
|
|
324
|
+
if (terrain) {
|
|
325
|
+
const t = validateTerrain(terrain);
|
|
326
|
+
if (!t.valid)
|
|
327
|
+
throw new MapEngineError('INVALID_LAYER', t.reason);
|
|
328
|
+
this.assertOffline({ terrain });
|
|
329
|
+
}
|
|
330
|
+
if (deepEqual(terrain, this.terrain))
|
|
331
|
+
return;
|
|
332
|
+
this.terrain = terrain ? structuredClone(terrain) : null;
|
|
333
|
+
if (this.mounted) {
|
|
334
|
+
this.renderer.setTerrain?.(this.terrain);
|
|
335
|
+
this.checkNeeds();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
setTilesets(tilesets) {
|
|
339
|
+
this.assertAlive();
|
|
340
|
+
const v = validateTilesets(tilesets);
|
|
341
|
+
if (!v.valid)
|
|
342
|
+
throw new MapEngineError('INVALID_LAYER', v.reason);
|
|
343
|
+
this.assertOffline({ tilesets });
|
|
344
|
+
if (deepEqual(tilesets, this.tilesets))
|
|
345
|
+
return;
|
|
346
|
+
this.tilesets = structuredClone(tilesets);
|
|
347
|
+
if (this.mounted) {
|
|
348
|
+
this.renderer.setTilesets?.(this.tilesets);
|
|
349
|
+
this.checkNeeds();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async sampleElevation(positions, signal) {
|
|
353
|
+
this.assertAlive();
|
|
354
|
+
for (const [i, p] of positions.entries()) {
|
|
355
|
+
const v = validatePosition(p, `positions[${i}]`);
|
|
356
|
+
if (!v.valid)
|
|
357
|
+
throw new MapEngineError('INVALID_GEOMETRY', v.reason);
|
|
358
|
+
}
|
|
359
|
+
if (this.deps.elevation)
|
|
360
|
+
return this.deps.elevation.sample(positions, signal);
|
|
361
|
+
if (this.mounted && this.renderer.capabilities?.elevation && this.renderer.sampleElevation)
|
|
362
|
+
return this.renderer.sampleElevation(positions);
|
|
363
|
+
throw new MapEngineError('MISSING_CAPABILITY', 'ground heights need deps.elevation or a renderer with terrain (capability: elevation)');
|
|
364
|
+
}
|
|
365
|
+
async lineOfSight(from, to, options = {}) {
|
|
366
|
+
this.assertAlive();
|
|
367
|
+
const samples = pathSamples(from, to, Math.max(8, Math.min(1024, options.samples ?? 128)));
|
|
368
|
+
const ground = await this.sampleElevation(samples, options.signal);
|
|
369
|
+
const fromHeight = from.altitude ?? (ground[0] ?? 0) + (options.fromHeightAboveGround ?? 2);
|
|
370
|
+
const toHeight = to.altitude ?? (ground[ground.length - 1] ?? 0) + (options.toHeightAboveGround ?? 2);
|
|
371
|
+
return judgeSight(samples, ground, fromHeight, toHeight);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Compares what the map asks for with what the renderer can draw (ADR-026..028) and reports each gap once per
|
|
375
|
+
* renderer, instead of leaving a layer silently empty. The objects still load (lists and queries see them).
|
|
376
|
+
*/
|
|
377
|
+
checkNeeds() {
|
|
378
|
+
if (!this.mounted)
|
|
379
|
+
return;
|
|
380
|
+
const caps = this.renderer.capabilities ?? {};
|
|
381
|
+
const report = (key, capability, what, details) => {
|
|
382
|
+
if (caps[capability] || this.reportedNeeds.has(key))
|
|
383
|
+
return;
|
|
384
|
+
this.reportedNeeds.add(key);
|
|
385
|
+
this.emitter.emit('warning', new MapEngineError('MISSING_CAPABILITY', `${what} needs the renderer capability "${capability}", which the current renderer does not have`, { ...details, capability }));
|
|
386
|
+
};
|
|
387
|
+
if (this.projection === 'globe')
|
|
388
|
+
report('map:globe', 'globe', 'projection: globe', {});
|
|
389
|
+
if (this.terrain)
|
|
390
|
+
report('map:terrain', 'terrain', 'terrain', {});
|
|
391
|
+
if (this.tilesets.some((t) => t.show !== false))
|
|
392
|
+
report('map:tiles3d', 'tiles3d', '3D Tiles scenery', {});
|
|
393
|
+
for (const layer of this.layers.list()) {
|
|
394
|
+
if (!layer.visible)
|
|
395
|
+
continue;
|
|
396
|
+
for (const need of layerNeeds(layer))
|
|
397
|
+
report(`${layer.id}:${need}`, need, `layer ${layer.id} (${layer.visualization.type}${layer.visualization.elevation ? ', elevation' : ''})`, { layerId: layer.id });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/** Stops everything that needs the renderer or the network; keeps the map's state. */
|
|
401
|
+
release() {
|
|
402
|
+
this.mountGen++;
|
|
403
|
+
const hadRenderer = this.mounted || this.mounting;
|
|
404
|
+
this.mounting = false;
|
|
405
|
+
this.cancelScheduled();
|
|
406
|
+
this.controller?.abort();
|
|
407
|
+
this.unsubscribeRealtime();
|
|
408
|
+
if (this.reconnectTimer !== null)
|
|
409
|
+
clearTimeout(this.reconnectTimer);
|
|
410
|
+
this.reconnectTimer = null;
|
|
411
|
+
this.pending = [];
|
|
412
|
+
for (const t of [this.ageTimer, this.ageSoon])
|
|
413
|
+
if (t !== null)
|
|
414
|
+
clearTimeout(t);
|
|
415
|
+
this.ageTimer = null;
|
|
416
|
+
this.ageSoon = null;
|
|
417
|
+
this.annotationSub?.();
|
|
418
|
+
this.annotationSub = null;
|
|
419
|
+
for (const off of this.rendererSubs)
|
|
420
|
+
off();
|
|
421
|
+
this.rendererSubs = [];
|
|
422
|
+
if (hadRenderer) {
|
|
423
|
+
try {
|
|
424
|
+
this.renderer.destroy();
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// Releasing must always complete.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
this.mounted = false;
|
|
431
|
+
}
|
|
432
|
+
destroy() {
|
|
433
|
+
if (this.current === 'destroyed')
|
|
434
|
+
return;
|
|
435
|
+
this.release();
|
|
436
|
+
this.container = null;
|
|
437
|
+
this.index.clear();
|
|
438
|
+
this.layerData.clear();
|
|
439
|
+
this.displayed.clear();
|
|
440
|
+
this.trailData.clear();
|
|
441
|
+
this.replayTracks.clear();
|
|
442
|
+
this.journalData.clear();
|
|
443
|
+
this.linkData.clear();
|
|
444
|
+
if (this.highlightTimer !== null)
|
|
445
|
+
clearTimeout(this.highlightTimer);
|
|
446
|
+
this.setState('destroyed');
|
|
447
|
+
this.emitter.clear();
|
|
448
|
+
}
|
|
449
|
+
// ---------------------------------------------------------------- viewport
|
|
450
|
+
getViewport() {
|
|
451
|
+
return structuredClone(this.withBounds(this.viewport));
|
|
452
|
+
}
|
|
453
|
+
project(position) {
|
|
454
|
+
if (this.current === 'destroyed' || this.current === 'idle')
|
|
455
|
+
return null;
|
|
456
|
+
return this.renderer.project?.(position) ?? null;
|
|
457
|
+
}
|
|
458
|
+
resize() {
|
|
459
|
+
this.assertAlive();
|
|
460
|
+
if (this.mounted)
|
|
461
|
+
this.renderer.resize();
|
|
462
|
+
}
|
|
463
|
+
setViewport(viewport, options) {
|
|
464
|
+
this.assertAlive();
|
|
465
|
+
const v = validateViewport(viewport);
|
|
466
|
+
if (!v.valid)
|
|
467
|
+
throw new MapEngineError('INVALID_VIEWPORT', v.reason);
|
|
468
|
+
const next = normalizeViewport({ ...viewport });
|
|
469
|
+
delete next.bounds; // bounds are derived, never trusted from callers
|
|
470
|
+
this.viewport = next;
|
|
471
|
+
this.scene.setViewport(this.withBounds(next));
|
|
472
|
+
if (this.mounted)
|
|
473
|
+
this.renderer.setViewport(next, options);
|
|
474
|
+
this.emitter.emit('viewport', this.getViewport());
|
|
475
|
+
this.schedule();
|
|
476
|
+
}
|
|
477
|
+
fitBounds(bounds, options) {
|
|
478
|
+
const fit = zoomToFit(bounds, this.size(), options?.padding ?? 0);
|
|
479
|
+
this.setViewport({ ...fit, bearing: 0, pitch: 0 }, { animate: options?.animate ?? false });
|
|
480
|
+
}
|
|
481
|
+
async flyTo(to, options) {
|
|
482
|
+
this.assertAlive();
|
|
483
|
+
const animate = options?.animate ?? true;
|
|
484
|
+
let geometry;
|
|
485
|
+
if (typeof to === 'string') {
|
|
486
|
+
const loaded = this.lookup(to);
|
|
487
|
+
const object = loaded ?? (await this.deps.provider.getCurrentState([to])).find((o) => o?.objectId === to);
|
|
488
|
+
if (this.current === 'destroyed' || !object || !validateGeometry(object.geometry).valid)
|
|
489
|
+
return false;
|
|
490
|
+
geometry = object.geometry;
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const p = validatePosition(to, 'flyTo position');
|
|
494
|
+
if (!p.valid)
|
|
495
|
+
throw new MapEngineError('INVALID_VIEWPORT', p.reason);
|
|
496
|
+
geometry = { type: 'Point', coordinates: to };
|
|
497
|
+
}
|
|
498
|
+
if (geometry.type === 'Point') {
|
|
499
|
+
this.setViewport({ ...this.viewport, center: geometry.coordinates, zoom: options?.zoom ?? Math.max(this.viewport.zoom, 15) }, { animate });
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
const fit = zoomToFit(geometryBounds(geometry), this.size(), options?.padding ?? 40);
|
|
503
|
+
this.setViewport({ ...fit, zoom: Math.min(fit.zoom, options?.zoom ?? 18), bearing: this.viewport.bearing ?? 0, pitch: this.viewport.pitch ?? 0 }, { animate });
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
onRendererViewport(v) {
|
|
507
|
+
if (this.current === 'destroyed')
|
|
508
|
+
return;
|
|
509
|
+
const check = validateViewport(v);
|
|
510
|
+
if (!check.valid) {
|
|
511
|
+
this.emitter.emit('warning', new MapEngineError('INVALID_VIEWPORT', `renderer reported ${check.reason}`));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const next = normalizeViewport(v);
|
|
515
|
+
if (sameCamera(next, this.viewport)) {
|
|
516
|
+
if (!next.bounds || deepEqual(next.bounds, this.viewport.bounds))
|
|
517
|
+
return;
|
|
518
|
+
// Same camera, new visible area. First bounds after our own setViewport: adopt (a load is already
|
|
519
|
+
// scheduled). Otherwise the container was resized: the data boundary changed, so load again.
|
|
520
|
+
const resized = this.viewport.bounds !== undefined;
|
|
521
|
+
this.viewport = next;
|
|
522
|
+
this.scene.setViewport(next);
|
|
523
|
+
if (resized) {
|
|
524
|
+
this.emitter.emit('viewport', this.getViewport());
|
|
525
|
+
this.schedule();
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
this.viewport = next;
|
|
530
|
+
this.scene.setViewport(this.withBounds(next));
|
|
531
|
+
this.emitter.emit('viewport', this.getViewport());
|
|
532
|
+
this.schedule();
|
|
533
|
+
}
|
|
534
|
+
// ---------------------------------------------------------------- layers
|
|
535
|
+
getLayers() {
|
|
536
|
+
return this.layers.list();
|
|
537
|
+
}
|
|
538
|
+
addLayer(layer) {
|
|
539
|
+
this.assertAlive();
|
|
540
|
+
this.assertOffline({ layers: [layer] });
|
|
541
|
+
this.layers.add(layer);
|
|
542
|
+
this.applyLayers();
|
|
543
|
+
this.checkNeeds();
|
|
544
|
+
if (layer.visible)
|
|
545
|
+
this.schedule();
|
|
546
|
+
this.emitter.emit('layerChanged', { action: 'added', layerId: layer.id });
|
|
547
|
+
}
|
|
548
|
+
updateLayer(id, patch) {
|
|
549
|
+
this.assertAlive();
|
|
550
|
+
const before = this.layers.get(id);
|
|
551
|
+
if (before)
|
|
552
|
+
this.assertOffline({ layers: [{ ...before, ...patch, id }] });
|
|
553
|
+
const after = this.layers.update(id, patch);
|
|
554
|
+
const sourceChanged = !deepEqual(before?.source, after.source);
|
|
555
|
+
const becameVisible = !before?.visible && after.visible;
|
|
556
|
+
if (sourceChanged)
|
|
557
|
+
this.layerData.delete(id);
|
|
558
|
+
this.applyLayers();
|
|
559
|
+
this.checkNeeds();
|
|
560
|
+
if (after.visible && (sourceChanged || becameVisible))
|
|
561
|
+
this.schedule();
|
|
562
|
+
const fields = changedFields(before, after);
|
|
563
|
+
if (fields.length)
|
|
564
|
+
this.emitter.emit('layerChanged', { action: 'updated', layerId: id, fields });
|
|
565
|
+
if (before && FILTER_FIELDS.some((f) => fields.includes(`source.${f}`))) {
|
|
566
|
+
this.emitter.emit('filterChanged', { layerId: id, before: filterOf(before.source), after: filterOf(after.source) });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
removeLayer(id) {
|
|
570
|
+
this.assertAlive();
|
|
571
|
+
this.layers.remove(id);
|
|
572
|
+
this.layerData.delete(id);
|
|
573
|
+
this.displayed.delete(id);
|
|
574
|
+
this.linkData.delete(id);
|
|
575
|
+
this.layerStatus.delete(id);
|
|
576
|
+
this.applyLayers();
|
|
577
|
+
this.rebuildIndex();
|
|
578
|
+
this.emitter.emit('layerChanged', { action: 'removed', layerId: id });
|
|
579
|
+
}
|
|
580
|
+
setLayerVisibility(id, visible) {
|
|
581
|
+
this.updateLayer(id, { visible });
|
|
582
|
+
}
|
|
583
|
+
setLayerZIndex(id, zIndex) {
|
|
584
|
+
this.updateLayer(id, { zIndex });
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* What the scene draws: the application's layers as renderers get them (effectiveLayer), each visible layer's
|
|
588
|
+
* decorations, the engine overlays (annotations, proposals, highlight) and, while drawing, the draft.
|
|
589
|
+
*/
|
|
590
|
+
sceneLayers() {
|
|
591
|
+
const out = [];
|
|
592
|
+
for (const l of this.layers.list()) {
|
|
593
|
+
out.push(effectiveLayer(l));
|
|
594
|
+
if (l.visible)
|
|
595
|
+
out.push(...decorationLayers(l));
|
|
596
|
+
}
|
|
597
|
+
// Overlays only while they have something to show: a plain map carries no empty engine layers.
|
|
598
|
+
if (this.annotationStore.size)
|
|
599
|
+
out.push(...(this.annotationsVisible ? ANNOTATION_LAYERS : ANNOTATION_LAYERS.map((l) => ({ ...l, visible: false }))));
|
|
600
|
+
if (this.proposals.size)
|
|
601
|
+
out.push(...PROPOSAL_LAYERS);
|
|
602
|
+
if (this.highlighted)
|
|
603
|
+
out.push(...highlightLayers(this.highlighted.color ?? HIGHLIGHT_COLOR));
|
|
604
|
+
if (this.drawing)
|
|
605
|
+
out.push(...DRAFT_LAYERS);
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
/** Pushes layer definitions to the scene and re-applies cached data (style/visualization changes need no query). */
|
|
609
|
+
applyLayers() {
|
|
610
|
+
const patches = [this.scene.setLayers(this.sceneLayers())];
|
|
611
|
+
for (const layer of this.layers.visible()) {
|
|
612
|
+
if (layer.visualization.type === 'link') {
|
|
613
|
+
patches.push(this.linkPatch(layer));
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const data = this.displayed.get(layer.id) ?? this.layerData.get(layer.id);
|
|
617
|
+
if (data)
|
|
618
|
+
patches.push(this.show(layer, data));
|
|
619
|
+
}
|
|
620
|
+
patches.push(this.overlayPatch());
|
|
621
|
+
if (this.drawing)
|
|
622
|
+
for (const [layerId, objects] of Object.entries(draftObjects(this.drawing)))
|
|
623
|
+
patches.push(this.scene.setLayerObjects(layerId, objects));
|
|
624
|
+
this.push(mergePatches(...patches));
|
|
625
|
+
this.scheduleAge();
|
|
626
|
+
}
|
|
627
|
+
/** The map's time for presentation: the clock when live, the replay time in replay. */
|
|
628
|
+
presentNow() {
|
|
629
|
+
return this.time.mode === 'replay' ? toMs(this.time.at) : this.now();
|
|
630
|
+
}
|
|
631
|
+
/** Shows a layer's objects as they look at the map's time (doc 28 G1–G2), with the layer's decorations. */
|
|
632
|
+
show(layer, objects) {
|
|
633
|
+
const now = this.presentNow();
|
|
634
|
+
const shown = [];
|
|
635
|
+
for (const o of objects) {
|
|
636
|
+
const p = presentObject(layer, o, now);
|
|
637
|
+
if (p)
|
|
638
|
+
shown.push(p);
|
|
639
|
+
}
|
|
640
|
+
const patches = [this.scene.setLayerObjects(layer.id, shown)];
|
|
641
|
+
for (const [id, list] of Object.entries(decorationObjects(layer, shown)))
|
|
642
|
+
patches.push(this.scene.setLayerObjects(id, list));
|
|
643
|
+
return mergePatches(...patches);
|
|
644
|
+
}
|
|
645
|
+
showId(layerId, objects) {
|
|
646
|
+
const layer = this.layers.get(layerId);
|
|
647
|
+
return layer ? this.show(layer, objects) : this.scene.setLayerObjects(layerId, objects);
|
|
648
|
+
}
|
|
649
|
+
/** Live changes of some objects: only those are presented again; decorations follow the whole layer. */
|
|
650
|
+
showChanges(layer, upserted, removed, all) {
|
|
651
|
+
const now = this.presentNow();
|
|
652
|
+
const up = [];
|
|
653
|
+
const gone = [...removed];
|
|
654
|
+
for (const o of upserted) {
|
|
655
|
+
const p = presentObject(layer, o, now);
|
|
656
|
+
if (p)
|
|
657
|
+
up.push(p);
|
|
658
|
+
else
|
|
659
|
+
gone.push(o.objectId);
|
|
660
|
+
}
|
|
661
|
+
const patches = [this.scene.patchLayerObjects(layer.id, up, gone)];
|
|
662
|
+
if (decorationLayers(layer).length) {
|
|
663
|
+
const shown = all.map((o) => presentObject(layer, o, now)).filter((o) => o !== null);
|
|
664
|
+
for (const [id, list] of Object.entries(decorationObjects(layer, shown)))
|
|
665
|
+
patches.push(this.scene.setLayerObjects(id, list));
|
|
666
|
+
}
|
|
667
|
+
return mergePatches(...patches);
|
|
668
|
+
}
|
|
669
|
+
/** Overlays changed: their layers come or go with them, then their objects. */
|
|
670
|
+
refreshOverlays() {
|
|
671
|
+
this.push(mergePatches(this.scene.setLayers(this.sceneLayers()), this.overlayPatch()));
|
|
672
|
+
}
|
|
673
|
+
/** The engine overlays' objects: annotations, proposals, and the halo of highlighted objects where they are now. */
|
|
674
|
+
overlayPatch() {
|
|
675
|
+
const groups = { ...annotationObjects(this.annotationStore.values()), ...proposalObjects(this.proposals.values()), ...highlightObjects(this.highlighted, (id) => this.index.get(id)) };
|
|
676
|
+
return mergePatches(...Object.entries(groups).map(([id, list]) => this.scene.setLayerObjects(id, list)));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Redraws objects whose age state changes, or that enter or leave their validity window, exactly when that
|
|
680
|
+
* happens (live only; replay presents at the replay time on every frame).
|
|
681
|
+
*/
|
|
682
|
+
scheduleAge() {
|
|
683
|
+
if (this.ageTimer !== null)
|
|
684
|
+
clearTimeout(this.ageTimer);
|
|
685
|
+
this.ageTimer = null;
|
|
686
|
+
if (this.time.mode !== 'live' || !this.mounted || this.current === 'destroyed')
|
|
687
|
+
return;
|
|
688
|
+
const now = this.now();
|
|
689
|
+
let next = Number.POSITIVE_INFINITY;
|
|
690
|
+
for (const layer of this.layers.visible()) {
|
|
691
|
+
const list = this.displayed.get(layer.id);
|
|
692
|
+
if (!list || isDerived(layer))
|
|
693
|
+
continue;
|
|
694
|
+
const age = layer.visualization.age;
|
|
695
|
+
for (const o of list) {
|
|
696
|
+
const a = nextAgeChangeMs(o, age, now);
|
|
697
|
+
if (a !== null && a < next)
|
|
698
|
+
next = a;
|
|
699
|
+
for (const t of [o.validFrom, o.validUntil]) {
|
|
700
|
+
if (!t)
|
|
701
|
+
continue;
|
|
702
|
+
const d = Date.parse(t) - now;
|
|
703
|
+
if (d > 0 && d < next)
|
|
704
|
+
next = d;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
if (!Number.isFinite(next))
|
|
709
|
+
return;
|
|
710
|
+
this.ageTimer = setTimeout(() => {
|
|
711
|
+
this.ageTimer = null;
|
|
712
|
+
if (this.current === 'destroyed' || this.time.mode !== 'live')
|
|
713
|
+
return;
|
|
714
|
+
const patches = [];
|
|
715
|
+
for (const layer of this.layers.visible()) {
|
|
716
|
+
const list = this.displayed.get(layer.id);
|
|
717
|
+
if (list && !isDerived(layer))
|
|
718
|
+
patches.push(this.show(layer, list));
|
|
719
|
+
}
|
|
720
|
+
this.push(mergePatches(...patches));
|
|
721
|
+
this.scheduleAge();
|
|
722
|
+
}, Math.min(Math.max(next + 5, 50), 2_147_000_000));
|
|
723
|
+
}
|
|
724
|
+
/** Live frames come many times a second: the age schedule is recomputed at most a few times a second. */
|
|
725
|
+
scheduleAgeSoon() {
|
|
726
|
+
if (this.ageSoon !== null)
|
|
727
|
+
return;
|
|
728
|
+
this.ageSoon = setTimeout(() => {
|
|
729
|
+
this.ageSoon = null;
|
|
730
|
+
this.scheduleAge();
|
|
731
|
+
}, 300);
|
|
732
|
+
}
|
|
733
|
+
// ---------------------------------------------------------------- drawing (doc §37)
|
|
734
|
+
startDrawing(kind) {
|
|
735
|
+
this.assertAlive();
|
|
736
|
+
this.drawing = { kind, points: [], canFinish: false };
|
|
737
|
+
this.applyLayers();
|
|
738
|
+
this.emitter.emit('drawing', structuredClone(this.drawing));
|
|
739
|
+
}
|
|
740
|
+
addDrawingPoint(position) {
|
|
741
|
+
this.assertAlive();
|
|
742
|
+
if (!this.drawing)
|
|
743
|
+
return;
|
|
744
|
+
const check = validatePosition(position);
|
|
745
|
+
if (!check.valid)
|
|
746
|
+
throw new MapEngineError('INVALID_GEOMETRY', check.reason);
|
|
747
|
+
const points = [...this.drawing.points, { latitude: position.latitude, longitude: position.longitude }];
|
|
748
|
+
this.drawing = { kind: this.drawing.kind, points, ...drawingCheck(this.drawing.kind, points) };
|
|
749
|
+
if (autoFinishes(this.drawing.kind, points.length)) {
|
|
750
|
+
if (this.finishDrawing())
|
|
751
|
+
return;
|
|
752
|
+
// A degenerate shape (zero-size rectangle or circle): drop the last click and keep drawing.
|
|
753
|
+
this.drawing = { ...this.drawing, points: points.slice(0, -1), canFinish: false };
|
|
754
|
+
}
|
|
755
|
+
this.updateDraft();
|
|
756
|
+
}
|
|
757
|
+
undoDrawingPoint() {
|
|
758
|
+
if (!this.drawing || !this.drawing.points.length)
|
|
759
|
+
return;
|
|
760
|
+
const points = this.drawing.points.slice(0, -1);
|
|
761
|
+
this.drawing = { kind: this.drawing.kind, points, ...drawingCheck(this.drawing.kind, points) };
|
|
762
|
+
this.updateDraft();
|
|
763
|
+
}
|
|
764
|
+
finishDrawing() {
|
|
765
|
+
if (!this.drawing)
|
|
766
|
+
return null;
|
|
767
|
+
const { kind, points } = this.drawing;
|
|
768
|
+
const geometry = drawnGeometry(kind, points);
|
|
769
|
+
if (!geometry)
|
|
770
|
+
return null;
|
|
771
|
+
const check = validateGeometry(geometry);
|
|
772
|
+
if (!check.valid) {
|
|
773
|
+
// Drawing continues: the user can undo the point that made the shape invalid.
|
|
774
|
+
this.emitter.emit('warning', new MapEngineError('INVALID_GEOMETRY', `drawn ${kind}: ${check.reason}`));
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
this.drawing = null;
|
|
778
|
+
this.applyLayers();
|
|
779
|
+
this.emitter.emit('drawing', null);
|
|
780
|
+
this.emitter.emit('geometryDrawn', { kind, geometry: structuredClone(geometry) });
|
|
781
|
+
return geometry;
|
|
782
|
+
}
|
|
783
|
+
cancelDrawing() {
|
|
784
|
+
if (!this.drawing)
|
|
785
|
+
return;
|
|
786
|
+
this.drawing = null;
|
|
787
|
+
this.applyLayers();
|
|
788
|
+
this.emitter.emit('drawing', null);
|
|
789
|
+
}
|
|
790
|
+
getDrawing() {
|
|
791
|
+
return this.drawing ? structuredClone(this.drawing) : null;
|
|
792
|
+
}
|
|
793
|
+
updateDraft() {
|
|
794
|
+
if (!this.drawing)
|
|
795
|
+
return;
|
|
796
|
+
const patches = Object.entries(draftObjects(this.drawing)).map(([layerId, objects]) => this.scene.setLayerObjects(layerId, objects));
|
|
797
|
+
this.push(mergePatches(...patches));
|
|
798
|
+
this.emitter.emit('drawing', structuredClone(this.drawing));
|
|
799
|
+
}
|
|
800
|
+
// ---------------------------------------------------------------- selection + hover
|
|
801
|
+
getSelection() {
|
|
802
|
+
return this.selection.get();
|
|
803
|
+
}
|
|
804
|
+
selectObject(objectId, source = 'api') {
|
|
805
|
+
this.assertAlive();
|
|
806
|
+
if (this.selection.set([objectId], source))
|
|
807
|
+
this.afterSelection();
|
|
808
|
+
}
|
|
809
|
+
clearSelection(source = 'api') {
|
|
810
|
+
this.assertAlive();
|
|
811
|
+
if (this.selection.clear(source))
|
|
812
|
+
this.afterSelection();
|
|
813
|
+
}
|
|
814
|
+
setSelection(selection) {
|
|
815
|
+
this.assertAlive();
|
|
816
|
+
if (this.selection.set(selection.objectIds, selection.source, selection.primaryObjectId))
|
|
817
|
+
this.afterSelection();
|
|
818
|
+
}
|
|
819
|
+
afterSelection() {
|
|
820
|
+
const ctx = this.selection.get();
|
|
821
|
+
this.push(this.scene.setSelection(ctx));
|
|
822
|
+
this.emitter.emit('selection', ctx);
|
|
823
|
+
}
|
|
824
|
+
setSelectable(enabled) {
|
|
825
|
+
this.selectable = enabled;
|
|
826
|
+
}
|
|
827
|
+
onPick(e) {
|
|
828
|
+
if (this.current === 'destroyed')
|
|
829
|
+
return;
|
|
830
|
+
// While drawing, a click on an object is a point of the drawing, never a selection.
|
|
831
|
+
if (this.drawing)
|
|
832
|
+
return void (e.position && this.addDrawingPoint(e.position));
|
|
833
|
+
// A decoration (error area, ring, footprint, alternative) stands for its object.
|
|
834
|
+
const owner = decorationOwner(e.layerId, e.objectId);
|
|
835
|
+
const layerId = owner?.layerId ?? e.layerId;
|
|
836
|
+
const objectId = owner?.objectId ?? e.objectId;
|
|
837
|
+
if (e.position)
|
|
838
|
+
this.emitter.emit('click', { position: e.position, hit: { objectId, layerId } });
|
|
839
|
+
if (!this.selectable)
|
|
840
|
+
return;
|
|
841
|
+
if (SELECTABLE_OVERLAYS.has(layerId))
|
|
842
|
+
return this.selectObject(objectId, 'map');
|
|
843
|
+
const layer = this.layers.get(layerId);
|
|
844
|
+
if (!layer || layer.interaction?.selectable === false)
|
|
845
|
+
return;
|
|
846
|
+
this.selectObject(objectId, 'map');
|
|
847
|
+
}
|
|
848
|
+
onHover(e) {
|
|
849
|
+
if (this.current === 'destroyed')
|
|
850
|
+
return;
|
|
851
|
+
let hit = e;
|
|
852
|
+
if (e) {
|
|
853
|
+
const owner = decorationOwner(e.layerId, e.objectId);
|
|
854
|
+
hit = owner ? { ...e, ...owner } : e;
|
|
855
|
+
const layer = this.layers.get(hit.layerId);
|
|
856
|
+
if (!SELECTABLE_OVERLAYS.has(hit.layerId) && (!layer || layer.interaction?.hoverable === false))
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this.push(this.scene.setHover(hit ? hit.objectId : null));
|
|
860
|
+
this.emitter.emit('hover', hit);
|
|
861
|
+
}
|
|
862
|
+
// ---------------------------------------------------------------- data
|
|
863
|
+
getScene() {
|
|
864
|
+
return this.scene.getState();
|
|
865
|
+
}
|
|
866
|
+
getObject(objectId) {
|
|
867
|
+
const o = this.lookup(objectId);
|
|
868
|
+
return o ? structuredClone(o) : undefined;
|
|
869
|
+
}
|
|
870
|
+
inspect(objectId, source = 'api') {
|
|
871
|
+
const o = this.lookup(objectId);
|
|
872
|
+
if (!o)
|
|
873
|
+
return undefined;
|
|
874
|
+
const layerIds = [...this.displayed.entries()].filter(([, list]) => list.some((x) => x.objectId === objectId)).map(([id]) => id);
|
|
875
|
+
this.emitter.emit('objectInspected', { objectId, source, layerIds });
|
|
876
|
+
const ageing = layerIds.map((id) => this.layers.get(id)?.visualization.age).find((a) => a);
|
|
877
|
+
const age = ageOf(o, ageing, this.presentNow());
|
|
878
|
+
return { object: structuredClone(o), layerIds, selected: this.selection.get().objectIds.includes(objectId), time: this.getTime(), ...(age ? { age } : {}) };
|
|
879
|
+
}
|
|
880
|
+
queryLoaded(query) {
|
|
881
|
+
const candidates = query.bounds ? this.index.search(query.bounds) : this.index.all();
|
|
882
|
+
return rankByRelation(candidates.filter((o) => matchesQuery(o, query)), query).map((o) => structuredClone(o));
|
|
883
|
+
}
|
|
884
|
+
async query(query, signal) {
|
|
885
|
+
this.assertAlive();
|
|
886
|
+
const started = this.now();
|
|
887
|
+
try {
|
|
888
|
+
const raw = await this.deps.provider.query(structuredClone(query), signal);
|
|
889
|
+
// Untrusted input: keep only well-formed objects (doc §64).
|
|
890
|
+
const objects = raw.filter((o) => o && typeof o.objectId === 'string' && validateGeometry(o.geometry).valid);
|
|
891
|
+
this.emitter.emit('spatialQuery', { query: structuredClone(query), count: objects.length, durationMs: this.now() - started });
|
|
892
|
+
return objects;
|
|
893
|
+
}
|
|
894
|
+
catch (e) {
|
|
895
|
+
if (isAbortError(e))
|
|
896
|
+
throw e;
|
|
897
|
+
const error = new MapEngineError('PROVIDER_ERROR', `spatial query failed: ${errorMessage(e)}`, undefined, { cause: e });
|
|
898
|
+
this.emitter.emit('spatialQuery', { query: structuredClone(query), count: 0, durationMs: this.now() - started, error: error.message });
|
|
899
|
+
throw error;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
getStats() {
|
|
903
|
+
return structuredClone(this.stats);
|
|
904
|
+
}
|
|
905
|
+
async whenIdle() {
|
|
906
|
+
while (this.timer !== null || this.inflight) {
|
|
907
|
+
if (this.inflight)
|
|
908
|
+
await this.inflight;
|
|
909
|
+
else
|
|
910
|
+
await new Promise((r) => setTimeout(r, this.debounceMs + 1));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
refresh() {
|
|
914
|
+
this.assertAlive();
|
|
915
|
+
if (!this.mounted)
|
|
916
|
+
return Promise.reject(new MapEngineError('NOT_MOUNTED', 'mount the runtime before loading data'));
|
|
917
|
+
this.cancelScheduled();
|
|
918
|
+
this.controller?.abort(); // a newer request supersedes the older one
|
|
919
|
+
const controller = new AbortController();
|
|
920
|
+
this.controller = controller;
|
|
921
|
+
const run = this.load(controller.signal).finally(() => {
|
|
922
|
+
if (this.inflight === run)
|
|
923
|
+
this.inflight = null;
|
|
924
|
+
});
|
|
925
|
+
this.inflight = run;
|
|
926
|
+
return run;
|
|
927
|
+
}
|
|
928
|
+
/** Ontology first (doc 12): a type without spatial capability is reported, never guessed (doc 20). Cached per type. */
|
|
929
|
+
resolveBinding(ontologyType) {
|
|
930
|
+
let p = this.bindings.get(ontologyType);
|
|
931
|
+
if (!p) {
|
|
932
|
+
p = this.deps.ontology.resolve(ontologyType).then((r) => isMissingCapability(r)
|
|
933
|
+
? { error: new MapEngineError('MISSING_CAPABILITY', `${ontologyType}: ${r.reason}`, { ontologyType }) }
|
|
934
|
+
: r, (e) => {
|
|
935
|
+
this.bindings.delete(ontologyType); // retry next load
|
|
936
|
+
return { error: new MapEngineError('PROVIDER_ERROR', `ontology lookup for ${ontologyType} failed: ${errorMessage(e)}`, { ontologyType }, { cause: e }) };
|
|
937
|
+
});
|
|
938
|
+
this.bindings.set(ontologyType, p);
|
|
939
|
+
}
|
|
940
|
+
return p;
|
|
941
|
+
}
|
|
942
|
+
/** What every provider query carries besides its filters (doc §13): the zoom, and the range while replaying. */
|
|
943
|
+
queryHints() {
|
|
944
|
+
return { zoom: this.viewport.zoom, ...(this.time.mode === 'replay' ? { time: { ...this.time.range } } : {}) };
|
|
945
|
+
}
|
|
946
|
+
async load(signal) {
|
|
947
|
+
const started = Date.now();
|
|
948
|
+
let visible = this.layers.visible();
|
|
949
|
+
this.unsubscribeRealtime();
|
|
950
|
+
if (!visible.length) {
|
|
951
|
+
this.setState('ready');
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
this.setState('loading');
|
|
955
|
+
const patches = [];
|
|
956
|
+
let failed = 0;
|
|
957
|
+
for (const l of visible)
|
|
958
|
+
if (!this.layerStatus.has(l.id))
|
|
959
|
+
this.setStatus(l.id, 'loading');
|
|
960
|
+
if (this.deps.ontology) {
|
|
961
|
+
const resolved = await Promise.all(visible.map((l) => (l.source.ontologyType ? this.resolveBinding(l.source.ontologyType) : Promise.resolve(null))));
|
|
962
|
+
if (signal.aborted || this.current === 'destroyed')
|
|
963
|
+
return;
|
|
964
|
+
visible = visible.filter((layer, i) => {
|
|
965
|
+
const r = resolved[i];
|
|
966
|
+
if (!r)
|
|
967
|
+
return true;
|
|
968
|
+
if ('error' in r) {
|
|
969
|
+
failed++;
|
|
970
|
+
this.emitter.emit('error', new MapEngineError(r.error.code, `layer ${layer.id}: ${r.error.message}`, { ...r.error.details, layerId: layer.id }));
|
|
971
|
+
this.setStatus(layer.id, r.error.code === 'MISSING_CAPABILITY' ? 'missing-capability' : 'error', r.error.message);
|
|
972
|
+
this.layerData.delete(layer.id);
|
|
973
|
+
this.displayed.delete(layer.id);
|
|
974
|
+
patches.push(this.showId(layer.id, []));
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
if (geometryFamily(r.geometryType) !== geometryFamily(SOURCE_GEOMETRY_FOR[layer.visualization.type])) {
|
|
978
|
+
this.emitter.emit('warning', new MapEngineError('INVALID_LAYER', `layer ${layer.id} draws ${layer.visualization.type} but ${r.ontologyType} is a ${r.geometryType}`, { layerId: layer.id }));
|
|
979
|
+
}
|
|
980
|
+
return true;
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
const bounds = this.withBounds(this.viewport).bounds;
|
|
984
|
+
// Track layers get their data from the history provider and link layers from the link provider, not from
|
|
985
|
+
// object queries.
|
|
986
|
+
const trackLayers = visible.filter((l) => l.visualization.type === 'track');
|
|
987
|
+
const linkLayers = visible.filter((l) => l.visualization.type === 'link');
|
|
988
|
+
visible = visible.filter((l) => !isDerived(l));
|
|
989
|
+
const plan = planQueries(visible, bounds, this.deps.provider.capabilities, this.queryHints());
|
|
990
|
+
this.stats.lastPlan = { calls: plan.calls.length, layers: plan.layers, deduplicated: plan.deduplicated, pushedDown: plan.pushedDown };
|
|
991
|
+
this.stats.providerCalls += plan.calls.length;
|
|
992
|
+
const results = await Promise.allSettled(plan.calls.map((c) => this.deps.provider.query(c.query, signal)));
|
|
993
|
+
if (signal.aborted || this.current === 'destroyed')
|
|
994
|
+
return;
|
|
995
|
+
results.forEach((res, i) => {
|
|
996
|
+
const call = plan.calls[i];
|
|
997
|
+
if (res.status === 'rejected') {
|
|
998
|
+
if (isAbortError(res.reason))
|
|
999
|
+
return;
|
|
1000
|
+
for (const layerId of call.layerIds) {
|
|
1001
|
+
if (isDenied(res.reason)) {
|
|
1002
|
+
// Refused for this user: an expected state, not a failure. The layer shows nothing and says why.
|
|
1003
|
+
this.denied(layerId, errorMessage(res.reason));
|
|
1004
|
+
this.layerData.delete(layerId);
|
|
1005
|
+
this.displayed.delete(layerId);
|
|
1006
|
+
patches.push(this.showId(layerId, []));
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
failed++;
|
|
1010
|
+
this.setStatus(layerId, 'error', errorMessage(res.reason));
|
|
1011
|
+
this.emitter.emit('error', new MapEngineError('PROVIDER_ERROR', `layer ${layerId}: ${errorMessage(res.reason)}`, { layerId }, { cause: res.reason }));
|
|
1012
|
+
}
|
|
1013
|
+
return; // keep those layers' last good objects
|
|
1014
|
+
}
|
|
1015
|
+
const valid = this.validated(res.value, call.layerIds.join(','));
|
|
1016
|
+
for (const layerId of call.layerIds) {
|
|
1017
|
+
const layer = this.layers.get(layerId);
|
|
1018
|
+
// Exact client check of the layer's own query: needed when spatial parts were not pushed down,
|
|
1019
|
+
// and harmless (cheap) when they were.
|
|
1020
|
+
const q = layerQuery(layer, bounds);
|
|
1021
|
+
const objects = rankByRelation(valid.filter((o) => matchesQuery(o, q)), q);
|
|
1022
|
+
this.layerData.set(layerId, objects);
|
|
1023
|
+
this.setStatus(layerId, 'ok');
|
|
1024
|
+
if (this.time.mode === 'live') {
|
|
1025
|
+
this.displayed.set(layerId, objects);
|
|
1026
|
+
patches.push(this.showId(layerId, objects));
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
// Live objects do not wait for trails: history can be far slower than the latest values.
|
|
1031
|
+
if (this.time.mode === 'live' && trackLayers.length && patches.length) {
|
|
1032
|
+
this.rebuildIndex();
|
|
1033
|
+
this.push(mergePatches(...patches.splice(0)));
|
|
1034
|
+
}
|
|
1035
|
+
failed += await this.loadHistory(trackLayers, visible, bounds, signal);
|
|
1036
|
+
if (signal.aborted || this.current === 'destroyed')
|
|
1037
|
+
return;
|
|
1038
|
+
failed += await this.loadJournal(visible, bounds, signal);
|
|
1039
|
+
if (signal.aborted || this.current === 'destroyed')
|
|
1040
|
+
return;
|
|
1041
|
+
if (this.time.mode === 'replay') {
|
|
1042
|
+
patches.push(this.replayPatch());
|
|
1043
|
+
this.computeReplayEvents();
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
for (const l of trackLayers)
|
|
1047
|
+
patches.push(this.trailPatch(l));
|
|
1048
|
+
this.observeGeofences(visible.flatMap((l) => readingsFrom(this.layerData.get(l.id) ?? [], l.id, toIso(this.now()))));
|
|
1049
|
+
}
|
|
1050
|
+
this.rebuildIndex();
|
|
1051
|
+
// Links join objects that are on the map, so they load once those are known.
|
|
1052
|
+
failed += await this.loadLinks(linkLayers, signal);
|
|
1053
|
+
if (signal.aborted || this.current === 'destroyed')
|
|
1054
|
+
return;
|
|
1055
|
+
for (const l of linkLayers)
|
|
1056
|
+
patches.push(this.linkPatch(l));
|
|
1057
|
+
patches.push(this.overlayPatch());
|
|
1058
|
+
this.push(mergePatches(...patches));
|
|
1059
|
+
this.scheduleAge();
|
|
1060
|
+
this.stats.loads++;
|
|
1061
|
+
this.stats.lastLoadMs = Date.now() - started;
|
|
1062
|
+
this.setState(failed > 0 && failed >= this.layers.visible().length ? 'error' : 'ready');
|
|
1063
|
+
// Live only: replay shows the past, so the stream waits until the map is live again.
|
|
1064
|
+
if (this.time.mode === 'live') {
|
|
1065
|
+
this.subscribeRealtime(plan, results.map((r) => r.status === 'fulfilled'));
|
|
1066
|
+
this.subscribeTrails(trackLayers, bounds);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
// ---------------------------------------------------------------- time (doc 07)
|
|
1070
|
+
setTime(setting) {
|
|
1071
|
+
this.assertAlive();
|
|
1072
|
+
if (setting.mode === 'live') {
|
|
1073
|
+
if (this.time.mode === 'live')
|
|
1074
|
+
return;
|
|
1075
|
+
this.time = { mode: 'live' };
|
|
1076
|
+
this.replayTracks.clear();
|
|
1077
|
+
this.journalData.clear();
|
|
1078
|
+
this.emitter.emit('time', this.getTime());
|
|
1079
|
+
if (this.mounted)
|
|
1080
|
+
this.refresh().catch((e) => this.emitter.emit('error', toEngineError(e)));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const at = toMs(setting.at);
|
|
1084
|
+
if (!Number.isFinite(at))
|
|
1085
|
+
throw new MapEngineError('INVALID_LAYER', `replay time "${setting.at}" is not a valid ISO timestamp`);
|
|
1086
|
+
const current = this.time.mode === 'replay' ? this.time.range : undefined;
|
|
1087
|
+
const range = setting.range ?? (current && toMs(current.start) <= at && at <= toMs(current.end) ? current : { start: toIso(at - 2 * 3600_000), end: toIso(at) });
|
|
1088
|
+
if (!(toMs(range.end) > toMs(range.start)) || at < toMs(range.start) || at > toMs(range.end)) {
|
|
1089
|
+
throw new MapEngineError('INVALID_LAYER', 'replay time must lie inside its range, and the range must end after it starts');
|
|
1090
|
+
}
|
|
1091
|
+
const needsLoad = this.time.mode === 'live' || !current || current.start !== range.start || current.end !== range.end;
|
|
1092
|
+
this.time = { mode: 'replay', at: toIso(at), range };
|
|
1093
|
+
this.emitter.emit('time', this.getTime());
|
|
1094
|
+
if (!this.mounted)
|
|
1095
|
+
return;
|
|
1096
|
+
if (needsLoad) {
|
|
1097
|
+
this.unsubscribeRealtime();
|
|
1098
|
+
this.refresh().catch((e) => this.emitter.emit('error', toEngineError(e)));
|
|
1099
|
+
}
|
|
1100
|
+
else
|
|
1101
|
+
this.scheduleReplay();
|
|
1102
|
+
}
|
|
1103
|
+
getTime() {
|
|
1104
|
+
return structuredClone(this.time);
|
|
1105
|
+
}
|
|
1106
|
+
// ---------------------------------------------------------------- geofencing (doc 15)
|
|
1107
|
+
setGeofences(geofences) {
|
|
1108
|
+
this.assertAlive();
|
|
1109
|
+
const ids = new Set();
|
|
1110
|
+
for (const g of geofences) {
|
|
1111
|
+
const v = validateGeofence(g);
|
|
1112
|
+
if (!v.valid)
|
|
1113
|
+
throw new MapEngineError('INVALID_GEOFENCE', v.reason, { geofenceId: g?.id });
|
|
1114
|
+
if (ids.has(g.id))
|
|
1115
|
+
throw new MapEngineError('INVALID_GEOFENCE', `geofence ${g.id} appears twice`, { geofenceId: g.id });
|
|
1116
|
+
ids.add(g.id);
|
|
1117
|
+
}
|
|
1118
|
+
const previous = new Map(this.geofence.geofences().map((g) => [g.id, g]));
|
|
1119
|
+
this.geofence.setGeofences(geofences);
|
|
1120
|
+
this.stats.geofence.geofences = geofences.length;
|
|
1121
|
+
const changes = [];
|
|
1122
|
+
for (const g of geofences) {
|
|
1123
|
+
const old = previous.get(g.id);
|
|
1124
|
+
if (!old)
|
|
1125
|
+
changes.push({ action: 'created', geofenceId: g.id });
|
|
1126
|
+
else if (!deepEqual(old, g))
|
|
1127
|
+
changes.push({ action: 'updated', geofenceId: g.id });
|
|
1128
|
+
}
|
|
1129
|
+
for (const id of previous.keys())
|
|
1130
|
+
if (!ids.has(id))
|
|
1131
|
+
changes.push({ action: 'removed', geofenceId: id });
|
|
1132
|
+
if (this.time.mode === 'replay')
|
|
1133
|
+
this.computeReplayEvents();
|
|
1134
|
+
else {
|
|
1135
|
+
// New or changed geofences start from where objects are now, silently (no "enter" storm).
|
|
1136
|
+
const readings = [...this.displayed.entries()].flatMap(([layerId, objects]) => this.layers.get(layerId)?.visualization.type === 'track' ? [] : readingsFrom(objects, layerId, toIso(this.now())));
|
|
1137
|
+
this.observeGeofences(readings);
|
|
1138
|
+
}
|
|
1139
|
+
this.emitter.emit('geofences', this.geofence.geofences());
|
|
1140
|
+
for (const c of changes)
|
|
1141
|
+
this.emitter.emit('geofenceChanged', c);
|
|
1142
|
+
}
|
|
1143
|
+
getGeofences() {
|
|
1144
|
+
return this.geofence.geofences();
|
|
1145
|
+
}
|
|
1146
|
+
getGeofenceEvents(limit = 200) {
|
|
1147
|
+
if (this.time.mode === 'replay') {
|
|
1148
|
+
const at = toMs(this.time.at);
|
|
1149
|
+
return this.replayEvents.filter((e) => toMs(e.timestamp) <= at).reverse().slice(0, limit).map((e) => structuredClone(e));
|
|
1150
|
+
}
|
|
1151
|
+
return this.geofenceLog.slice(0, limit).map((e) => structuredClone(e));
|
|
1152
|
+
}
|
|
1153
|
+
getGeofenceOccupancy() {
|
|
1154
|
+
if (this.time.mode !== 'replay')
|
|
1155
|
+
return this.geofence.occupancy();
|
|
1156
|
+
// At the replay time: plain inside test on the replayed positions.
|
|
1157
|
+
const out = {};
|
|
1158
|
+
for (const g of this.geofence.geofences()) {
|
|
1159
|
+
if (!g.enabled)
|
|
1160
|
+
continue;
|
|
1161
|
+
const inside = new Set();
|
|
1162
|
+
for (const [layerId, objects] of this.displayed) {
|
|
1163
|
+
if (this.layers.get(layerId)?.visualization.type === 'track')
|
|
1164
|
+
continue;
|
|
1165
|
+
for (const r of readingsFrom(objects, layerId, this.time.at)) {
|
|
1166
|
+
if (watches(g, r) && within({ type: 'Point', coordinates: r.position }, g.geometry))
|
|
1167
|
+
inside.add(r.objectId);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
out[g.id] = [...inside].sort();
|
|
1171
|
+
}
|
|
1172
|
+
return out;
|
|
1173
|
+
}
|
|
1174
|
+
observeGeofences(readings) {
|
|
1175
|
+
for (const r of readings) {
|
|
1176
|
+
for (const e of this.geofence.observe(r)) {
|
|
1177
|
+
this.geofenceLog.unshift(e);
|
|
1178
|
+
this.stats.geofence.liveEvents++;
|
|
1179
|
+
this.emitter.emit('geofence', e);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (this.geofenceLog.length > 500)
|
|
1183
|
+
this.geofenceLog.length = 500;
|
|
1184
|
+
}
|
|
1185
|
+
computeReplayEvents() {
|
|
1186
|
+
const tracks = [...this.replayTracks.entries()].flatMap(([layerId, m]) => [...m.values()].map((track) => ({ track, layerId })));
|
|
1187
|
+
this.replayEvents = evaluateTracks(this.geofence.geofences(), tracks);
|
|
1188
|
+
this.stats.geofence.replayEvents = this.replayEvents.length;
|
|
1189
|
+
}
|
|
1190
|
+
/** Fetches trails (track layers) and, in replay, the tracks of point layers. Returns the number of failed layers. */
|
|
1191
|
+
async loadHistory(trackLayers, dataLayers, bounds, signal) {
|
|
1192
|
+
const history = this.deps.history;
|
|
1193
|
+
const replay = this.time.mode === 'replay' ? this.time : null;
|
|
1194
|
+
const pointLayers = replay ? dataLayers.filter((l) => GEOMETRY_FOR[l.visualization.type] === 'Point') : [];
|
|
1195
|
+
if (!trackLayers.length && !pointLayers.length)
|
|
1196
|
+
return 0;
|
|
1197
|
+
if (!history) {
|
|
1198
|
+
if (!this.warnedNoHistory && trackLayers.length) {
|
|
1199
|
+
this.warnedNoHistory = true;
|
|
1200
|
+
this.emitter.emit('warning', new MapEngineError('MISSING_CAPABILITY', 'track layers and replay need a history provider (deps.history)'));
|
|
1201
|
+
}
|
|
1202
|
+
return 0;
|
|
1203
|
+
}
|
|
1204
|
+
const nowMs = this.now();
|
|
1205
|
+
const jobs = [
|
|
1206
|
+
...trackLayers.map((layer) => {
|
|
1207
|
+
const windowMs = (layer.visualization.track?.windowMinutes ?? 30) * 60_000;
|
|
1208
|
+
const range = replay ? replay.range : { start: toIso(nowMs - windowMs), end: toIso(nowMs) };
|
|
1209
|
+
return { layer, range, store: this.trailData };
|
|
1210
|
+
}),
|
|
1211
|
+
...pointLayers.map((layer) => ({ layer, range: replay.range, store: this.replayTracks })),
|
|
1212
|
+
];
|
|
1213
|
+
this.stats.temporal.trackFetches += jobs.length;
|
|
1214
|
+
const results = await Promise.allSettled(jobs.map((j) => history.getTracks({ ...layerQuery(j.layer, bounds, { zoom: this.viewport.zoom }), geometryType: 'Point' }, j.range, signal)));
|
|
1215
|
+
if (signal.aborted)
|
|
1216
|
+
return 0;
|
|
1217
|
+
let failed = 0;
|
|
1218
|
+
results.forEach((res, i) => {
|
|
1219
|
+
const { layer, store } = jobs[i];
|
|
1220
|
+
if (res.status === 'rejected') {
|
|
1221
|
+
if (isAbortError(res.reason))
|
|
1222
|
+
return;
|
|
1223
|
+
if (isDenied(res.reason))
|
|
1224
|
+
return this.denied(layer.id, errorMessage(res.reason));
|
|
1225
|
+
failed++;
|
|
1226
|
+
if (layer.visualization.type === 'track')
|
|
1227
|
+
this.setStatus(layer.id, 'error', errorMessage(res.reason));
|
|
1228
|
+
this.emitter.emit('error', new MapEngineError('PROVIDER_ERROR', `layer ${layer.id} history: ${errorMessage(res.reason)}`, { layerId: layer.id }, { cause: res.reason }));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const tracks = new Map();
|
|
1232
|
+
for (const t of res.value) {
|
|
1233
|
+
const v = validateTrack(t);
|
|
1234
|
+
if (!v.valid) {
|
|
1235
|
+
this.emitter.emit('warning', new MapEngineError('INVALID_GEOMETRY', v.reason, { layerId: layer.id }));
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
tracks.set(t.objectId, normalizeTrack(t));
|
|
1239
|
+
}
|
|
1240
|
+
this.stats.temporal.tracksLoaded += tracks.size;
|
|
1241
|
+
store.set(layer.id, tracks);
|
|
1242
|
+
if (layer.visualization.type === 'track')
|
|
1243
|
+
this.setStatus(layer.id, 'ok');
|
|
1244
|
+
});
|
|
1245
|
+
return failed;
|
|
1246
|
+
}
|
|
1247
|
+
/** Trail objects (LineStrings) for a track layer, ending now (live) or at the replay time. */
|
|
1248
|
+
trailObjects(layer) {
|
|
1249
|
+
const tracks = this.trailData.get(layer.id);
|
|
1250
|
+
if (!tracks)
|
|
1251
|
+
return [];
|
|
1252
|
+
const replayAt = this.time.mode === 'replay' ? toMs(this.time.at) : null;
|
|
1253
|
+
const windowMs = (layer.visualization.track?.windowMinutes ?? 30) * 60_000;
|
|
1254
|
+
const out = [];
|
|
1255
|
+
for (const t of tracks.values()) {
|
|
1256
|
+
// Live trails end at now or at the newest point if a device clock runs slightly ahead of ours.
|
|
1257
|
+
const last = t.points.length ? toMs(t.points[t.points.length - 1].timestamp) : 0;
|
|
1258
|
+
const end = replayAt ?? Math.max(this.now(), last);
|
|
1259
|
+
const coords = trailBetween(t, end - windowMs, end);
|
|
1260
|
+
if (coords.length < 2)
|
|
1261
|
+
continue;
|
|
1262
|
+
out.push({ objectId: t.objectId, geometry: { type: 'LineString', coordinates: coords }, properties: { points: coords.length }, observedAt: toIso(end) });
|
|
1263
|
+
}
|
|
1264
|
+
return out;
|
|
1265
|
+
}
|
|
1266
|
+
trailPatch(layer) {
|
|
1267
|
+
const objects = this.trailObjects(layer);
|
|
1268
|
+
this.displayed.set(layer.id, objects);
|
|
1269
|
+
return this.scene.setLayerObjects(layer.id, objects);
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* The world at the replay time (doc 29 §8). With a journal, every object is what its last journal entry before
|
|
1273
|
+
* that time says (created later or removed before: not there). With tracks, point objects move along them.
|
|
1274
|
+
* Objects neither says anything about are treated as static and stay as they are now. Objects with a track but
|
|
1275
|
+
* no observation at that time are hidden. Replay sets `properties.heading` and `properties.speed` from the track
|
|
1276
|
+
* so icons can rotate.
|
|
1277
|
+
*/
|
|
1278
|
+
replayPatch() {
|
|
1279
|
+
if (this.time.mode !== 'replay')
|
|
1280
|
+
return {};
|
|
1281
|
+
const atMs = toMs(this.time.at);
|
|
1282
|
+
const bounds = this.withBounds(this.viewport).bounds;
|
|
1283
|
+
const patches = [];
|
|
1284
|
+
for (const layer of this.layers.visible()) {
|
|
1285
|
+
if (layer.visualization.type === 'track') {
|
|
1286
|
+
patches.push(this.trailPatch(layer));
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
if (isDerived(layer))
|
|
1290
|
+
continue;
|
|
1291
|
+
const tracks = GEOMETRY_FOR[layer.visualization.type] === 'Point' ? this.replayTracks.get(layer.id) : undefined;
|
|
1292
|
+
const journal = this.journalData.get(layer.id);
|
|
1293
|
+
if (!tracks && !journal)
|
|
1294
|
+
continue;
|
|
1295
|
+
const current = new Map((this.layerData.get(layer.id) ?? []).map((o) => [o.objectId, o]));
|
|
1296
|
+
const ids = new Set([...current.keys(), ...(tracks?.keys() ?? []), ...(journal?.keys() ?? [])]);
|
|
1297
|
+
const q = layerQuery(layer, bounds);
|
|
1298
|
+
const objects = [];
|
|
1299
|
+
for (const id of ids) {
|
|
1300
|
+
const said = journal ? journalStateAt(journal.get(id), atMs) : undefined;
|
|
1301
|
+
if (said === null)
|
|
1302
|
+
continue; // not there at that time
|
|
1303
|
+
let o = said ? { ...said, objectId: id } : current.get(id);
|
|
1304
|
+
const track = tracks?.get(id);
|
|
1305
|
+
if (track) {
|
|
1306
|
+
const s = stateAt(track, atMs);
|
|
1307
|
+
if (s) {
|
|
1308
|
+
o = {
|
|
1309
|
+
...(o ?? { objectId: id }),
|
|
1310
|
+
objectId: id,
|
|
1311
|
+
geometry: { type: 'Point', coordinates: s.position },
|
|
1312
|
+
properties: { ...(o?.properties ?? track.properties ?? {}), ...(s.heading !== undefined ? { heading: s.heading } : {}), ...(s.speed !== undefined ? { speed: s.speed } : {}) },
|
|
1313
|
+
observedAt: toIso(atMs),
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
else if (said === undefined)
|
|
1317
|
+
continue; // a moving object with no position at that time
|
|
1318
|
+
}
|
|
1319
|
+
if (o && validateGeometry(o.geometry).valid && matchesQuery(o, { ...q, objectIds: undefined }))
|
|
1320
|
+
objects.push(o);
|
|
1321
|
+
}
|
|
1322
|
+
const ranked = rankByRelation(objects, q);
|
|
1323
|
+
this.displayed.set(layer.id, ranked);
|
|
1324
|
+
patches.push(this.show(layer, ranked));
|
|
1325
|
+
}
|
|
1326
|
+
return mergePatches(...patches);
|
|
1327
|
+
}
|
|
1328
|
+
/** After the index follows the objects: links between them and the highlight halo move with them. */
|
|
1329
|
+
followPatch() {
|
|
1330
|
+
const patches = [];
|
|
1331
|
+
for (const layer of this.layers.visible())
|
|
1332
|
+
if (layer.visualization.type === 'link')
|
|
1333
|
+
patches.push(this.linkPatch(layer));
|
|
1334
|
+
if (this.highlighted)
|
|
1335
|
+
patches.push(this.overlayPatch());
|
|
1336
|
+
return mergePatches(...patches);
|
|
1337
|
+
}
|
|
1338
|
+
/** Replay only: the journal of each data layer for the range, grouped by object (doc 29 §7). */
|
|
1339
|
+
async loadJournal(layers, bounds, signal) {
|
|
1340
|
+
this.journalData.clear();
|
|
1341
|
+
if (this.time.mode !== 'replay' || !layers.length)
|
|
1342
|
+
return 0;
|
|
1343
|
+
const journal = this.deps.journal;
|
|
1344
|
+
if (!journal) {
|
|
1345
|
+
this.warnOnce('journal', 'replay rebuilds positions only: whole objects at a time (status, readings, appearing and disappearing) need an event journal (deps.journal)');
|
|
1346
|
+
return 0;
|
|
1347
|
+
}
|
|
1348
|
+
const range = this.time.range;
|
|
1349
|
+
const results = await Promise.allSettled(layers.map((l) => journal.getJournal(layerQuery(l, bounds, { zoom: this.viewport.zoom }), range, signal)));
|
|
1350
|
+
if (signal.aborted)
|
|
1351
|
+
return 0;
|
|
1352
|
+
let failed = 0;
|
|
1353
|
+
results.forEach((res, i) => {
|
|
1354
|
+
const layer = layers[i];
|
|
1355
|
+
if (res.status === 'rejected') {
|
|
1356
|
+
if (isAbortError(res.reason))
|
|
1357
|
+
return;
|
|
1358
|
+
if (isDenied(res.reason))
|
|
1359
|
+
return this.denied(layer.id, errorMessage(res.reason));
|
|
1360
|
+
failed++;
|
|
1361
|
+
this.emitter.emit('error', new MapEngineError('PROVIDER_ERROR', `layer ${layer.id} journal: ${errorMessage(res.reason)}`, { layerId: layer.id }, { cause: res.reason }));
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const valid = res.value.filter((e) => !e.object || validateGeometry(e.object.geometry).valid);
|
|
1365
|
+
this.journalData.set(layer.id, groupJournal(valid));
|
|
1366
|
+
});
|
|
1367
|
+
return failed;
|
|
1368
|
+
}
|
|
1369
|
+
/** Links touching the objects each link layer joins (doc 28 G4). Returns the number of failed layers. */
|
|
1370
|
+
async loadLinks(linkLayers, signal) {
|
|
1371
|
+
if (!linkLayers.length)
|
|
1372
|
+
return 0;
|
|
1373
|
+
const links = this.deps.links;
|
|
1374
|
+
if (!links) {
|
|
1375
|
+
for (const l of linkLayers)
|
|
1376
|
+
this.setStatus(l.id, 'missing-capability', 'link layers need a link provider (deps.links)');
|
|
1377
|
+
this.warnOnce('links', 'link layers need a link provider (deps.links)');
|
|
1378
|
+
return 0;
|
|
1379
|
+
}
|
|
1380
|
+
let failed = 0;
|
|
1381
|
+
await Promise.all(linkLayers.map(async (layer) => {
|
|
1382
|
+
const ends = this.linkEnds(layer);
|
|
1383
|
+
if (!ends.length) {
|
|
1384
|
+
this.linkData.set(layer.id, []);
|
|
1385
|
+
this.setStatus(layer.id, 'ok');
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
try {
|
|
1389
|
+
const list = await links.getLinks(ends, layer.visualization.link?.types, signal);
|
|
1390
|
+
this.linkData.set(layer.id, list.filter((k) => k && typeof k.id === 'string' && typeof k.fromId === 'string' && typeof k.toId === 'string'));
|
|
1391
|
+
this.setStatus(layer.id, 'ok');
|
|
1392
|
+
}
|
|
1393
|
+
catch (e) {
|
|
1394
|
+
if (isAbortError(e))
|
|
1395
|
+
return;
|
|
1396
|
+
if (isDenied(e))
|
|
1397
|
+
return this.denied(layer.id, errorMessage(e));
|
|
1398
|
+
failed++;
|
|
1399
|
+
this.setStatus(layer.id, 'error', errorMessage(e));
|
|
1400
|
+
this.emitter.emit('error', new MapEngineError('PROVIDER_ERROR', `layer ${layer.id} links: ${errorMessage(e)}`, { layerId: layer.id }, { cause: e }));
|
|
1401
|
+
}
|
|
1402
|
+
}));
|
|
1403
|
+
return failed;
|
|
1404
|
+
}
|
|
1405
|
+
/** The objects on the map a link layer joins: its source's objectIds, or the objects of its source's type. */
|
|
1406
|
+
linkEnds(layer) {
|
|
1407
|
+
const s = layer.source;
|
|
1408
|
+
if (s.objectIds)
|
|
1409
|
+
return [...s.objectIds];
|
|
1410
|
+
const out = new Set();
|
|
1411
|
+
for (const [layerId, list] of this.displayed) {
|
|
1412
|
+
const l = this.layers.get(layerId);
|
|
1413
|
+
if (!l || isDerived(l) || (s.ontologyType !== undefined && l.source.ontologyType !== s.ontologyType))
|
|
1414
|
+
continue;
|
|
1415
|
+
for (const o of list)
|
|
1416
|
+
out.add(o.objectId);
|
|
1417
|
+
}
|
|
1418
|
+
return [...out];
|
|
1419
|
+
}
|
|
1420
|
+
/** A link layer's lines between where its ends are now; a link with an end not on the map is not drawn. */
|
|
1421
|
+
linkPatch(layer) {
|
|
1422
|
+
const objects = [];
|
|
1423
|
+
for (const k of this.linkData.get(layer.id) ?? []) {
|
|
1424
|
+
const a = this.index.get(k.fromId);
|
|
1425
|
+
const b = this.index.get(k.toId);
|
|
1426
|
+
if (!a || !b)
|
|
1427
|
+
continue;
|
|
1428
|
+
objects.push({ objectId: k.id, geometry: { type: 'LineString', coordinates: [endPosition(a), endPosition(b)] }, properties: { ...k.properties, type: k.type ?? '', fromId: k.fromId, toId: k.toId } });
|
|
1429
|
+
}
|
|
1430
|
+
this.displayed.set(layer.id, objects);
|
|
1431
|
+
return this.show(layer, objects);
|
|
1432
|
+
}
|
|
1433
|
+
/** Scrubbing: many time changes → one re-interpolation per frame. */
|
|
1434
|
+
scheduleReplay() {
|
|
1435
|
+
if (this.replayQueued)
|
|
1436
|
+
return;
|
|
1437
|
+
this.replayQueued = true;
|
|
1438
|
+
this.frame(() => {
|
|
1439
|
+
this.replayQueued = false;
|
|
1440
|
+
if (this.current === 'destroyed' || this.time.mode !== 'replay')
|
|
1441
|
+
return;
|
|
1442
|
+
this.stats.temporal.replayFrames++;
|
|
1443
|
+
const patch = this.replayPatch();
|
|
1444
|
+
this.rebuildIndex();
|
|
1445
|
+
this.push(mergePatches(patch, this.followPatch()));
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
/** Live trails: track layers listen to point updates of their objects and grow (and trim) their trails. */
|
|
1449
|
+
subscribeTrails(trackLayers, bounds) {
|
|
1450
|
+
const rt = this.deps.realtime;
|
|
1451
|
+
if (!rt || !this.deps.history)
|
|
1452
|
+
return;
|
|
1453
|
+
for (const layer of trackLayers) {
|
|
1454
|
+
const off = rt.subscribe({ ...layerQuery(layer, bounds, { zoom: this.viewport.zoom }), geometryType: 'Point' }, (patch) => this.enqueue([layer.id], patch, true), (e) => this.onRealtimeError(e));
|
|
1455
|
+
this.subscriptions.push(off);
|
|
1456
|
+
}
|
|
1457
|
+
this.stats.realtime.subscriptions = this.subscriptions.length;
|
|
1458
|
+
}
|
|
1459
|
+
// ---------------------------------------------------------------- realtime (snapshot + stream)
|
|
1460
|
+
subscribeRealtime(plan, loaded) {
|
|
1461
|
+
const rt = this.deps.realtime;
|
|
1462
|
+
if (!rt)
|
|
1463
|
+
return;
|
|
1464
|
+
plan.calls.forEach((call, i) => {
|
|
1465
|
+
if (!loaded[i])
|
|
1466
|
+
return; // no snapshot, no stream
|
|
1467
|
+
const off = rt.subscribe(call.query, (patch) => this.enqueue(call.layerIds, patch), (e) => this.onRealtimeError(e));
|
|
1468
|
+
this.subscriptions.push(off);
|
|
1469
|
+
});
|
|
1470
|
+
this.stats.realtime.subscriptions = this.subscriptions.length;
|
|
1471
|
+
}
|
|
1472
|
+
unsubscribeRealtime() {
|
|
1473
|
+
for (const off of this.subscriptions) {
|
|
1474
|
+
try {
|
|
1475
|
+
off();
|
|
1476
|
+
}
|
|
1477
|
+
catch {
|
|
1478
|
+
// Unsubscribing must not fail a load.
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
this.subscriptions = [];
|
|
1482
|
+
this.stats.realtime.subscriptions = 0;
|
|
1483
|
+
}
|
|
1484
|
+
onRealtimeError(error) {
|
|
1485
|
+
if (this.current === 'destroyed')
|
|
1486
|
+
return;
|
|
1487
|
+
this.emitter.emit('error', new MapEngineError('PROVIDER_ERROR', `realtime: ${error.message}`, undefined, { cause: error }));
|
|
1488
|
+
this.unsubscribeRealtime();
|
|
1489
|
+
if (this.reconnectTimer !== null)
|
|
1490
|
+
return;
|
|
1491
|
+
// Reconnect = fresh snapshot, then stream again (ADR-015).
|
|
1492
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1493
|
+
this.reconnectTimer = null;
|
|
1494
|
+
if (this.current === 'destroyed' || !this.mounted)
|
|
1495
|
+
return;
|
|
1496
|
+
this.stats.realtime.reconnects++;
|
|
1497
|
+
this.refresh().catch((e) => this.emitter.emit('error', toEngineError(e)));
|
|
1498
|
+
}, this.reconnectMs);
|
|
1499
|
+
}
|
|
1500
|
+
/** Many events → one frame (doc 14 backpressure). */
|
|
1501
|
+
enqueue(layerIds, patch, trail = false) {
|
|
1502
|
+
if (this.current === 'destroyed' || this.time.mode !== 'live')
|
|
1503
|
+
return;
|
|
1504
|
+
this.stats.realtime.received += (patch.upserted?.length ?? 0) + (patch.removed?.length ?? 0);
|
|
1505
|
+
this.pending.push({ layerIds, patch, trail });
|
|
1506
|
+
if (this.frameQueued)
|
|
1507
|
+
return;
|
|
1508
|
+
this.frameQueued = true;
|
|
1509
|
+
this.frame(() => this.flush());
|
|
1510
|
+
}
|
|
1511
|
+
flush() {
|
|
1512
|
+
this.frameQueued = false;
|
|
1513
|
+
if (this.current === 'destroyed' || !this.pending.length)
|
|
1514
|
+
return;
|
|
1515
|
+
const batch = this.pending;
|
|
1516
|
+
this.pending = [];
|
|
1517
|
+
this.stats.realtime.frames++;
|
|
1518
|
+
const bounds = this.withBounds(this.viewport).bounds;
|
|
1519
|
+
const { touched, changes, fresh, trails } = applyFrame(batch, {
|
|
1520
|
+
layer: (id) => this.layers.get(id),
|
|
1521
|
+
current: (id) => this.layerData.get(id) ?? [],
|
|
1522
|
+
bounds,
|
|
1523
|
+
trustUntil: this.now() + this.skewToleranceMs,
|
|
1524
|
+
counters: this.stats.realtime,
|
|
1525
|
+
warn: (w) => this.emitter.emit('warning', w),
|
|
1526
|
+
saw: (o, layerId) => this.checkSkew(o, layerId),
|
|
1527
|
+
trail: (layerId, patch) => this.appendTrail(layerId, patch),
|
|
1528
|
+
});
|
|
1529
|
+
const liveReadings = fresh.flatMap(({ layerId, object }) => readingsFrom([object], layerId, toIso(this.now())));
|
|
1530
|
+
const patches = [];
|
|
1531
|
+
let fullIndex = false;
|
|
1532
|
+
for (const [layerId, objects] of touched) {
|
|
1533
|
+
const layer = this.layers.get(layerId);
|
|
1534
|
+
const ranked = layer?.source.spatialQuery?.relation === 'nearest';
|
|
1535
|
+
if (ranked || !layer) {
|
|
1536
|
+
// Rankings (nearest) are re-applied after live changes: one move can change who is in. Limitation: an
|
|
1537
|
+
// object ranked out is dropped until the next snapshot, even if a closer one later moves away.
|
|
1538
|
+
const list = layer ? rankByRelation([...objects.values()], layerQuery(layer, bounds)) : [...objects.values()];
|
|
1539
|
+
this.layerData.set(layerId, list);
|
|
1540
|
+
this.displayed.set(layerId, list);
|
|
1541
|
+
patches.push(this.showId(layerId, list));
|
|
1542
|
+
fullIndex = true;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
const list = [...objects.values()];
|
|
1546
|
+
this.layerData.set(layerId, list);
|
|
1547
|
+
this.displayed.set(layerId, list);
|
|
1548
|
+
const c = changes.get(layerId);
|
|
1549
|
+
if (c)
|
|
1550
|
+
patches.push(this.showChanges(layer, c.upserted.values(), c.removed, list));
|
|
1551
|
+
}
|
|
1552
|
+
for (const layerId of trails) {
|
|
1553
|
+
const layer = this.layers.get(layerId);
|
|
1554
|
+
if (layer && layer.visible)
|
|
1555
|
+
patches.push(this.trailPatch(layer));
|
|
1556
|
+
}
|
|
1557
|
+
if (fullIndex)
|
|
1558
|
+
this.rebuildIndex();
|
|
1559
|
+
else {
|
|
1560
|
+
// Once per frame, not per object (layers.get copies the layer).
|
|
1561
|
+
const derived = new Set(this.layers.list().filter(isDerived).map((l) => l.id));
|
|
1562
|
+
patchIndex(this.index, changes, touched, this.displayed, (id) => derived.has(id));
|
|
1563
|
+
}
|
|
1564
|
+
patches.push(this.followPatch());
|
|
1565
|
+
this.push(mergePatches(...patches));
|
|
1566
|
+
this.observeGeofences(liveReadings);
|
|
1567
|
+
this.scheduleAgeSoon();
|
|
1568
|
+
}
|
|
1569
|
+
/** Grows trails from live point updates; drops points older than the layer's window. Returns true if changed. */
|
|
1570
|
+
appendTrail(layerId, patch) {
|
|
1571
|
+
const layer = this.layers.get(layerId);
|
|
1572
|
+
if (!layer || !layer.visible)
|
|
1573
|
+
return false;
|
|
1574
|
+
let tracks = this.trailData.get(layerId);
|
|
1575
|
+
if (!tracks)
|
|
1576
|
+
this.trailData.set(layerId, (tracks = new Map()));
|
|
1577
|
+
const keepFrom = this.now() - (layer.visualization.track?.windowMinutes ?? 30) * 60_000;
|
|
1578
|
+
const { changed, added } = growTrails(tracks, patch, keepFrom, this.now());
|
|
1579
|
+
this.stats.temporal.trailPointsAppended += added;
|
|
1580
|
+
return changed;
|
|
1581
|
+
}
|
|
1582
|
+
/** Untrusted provider data: drop invalid geometry and duplicate ids, report each as a warning. */
|
|
1583
|
+
validated(objects, layerId) {
|
|
1584
|
+
const seen = new Set();
|
|
1585
|
+
const out = [];
|
|
1586
|
+
for (const o of objects) {
|
|
1587
|
+
if (!o || typeof o.objectId !== 'string' || o.objectId === '') {
|
|
1588
|
+
this.emitter.emit('warning', new MapEngineError('INVALID_GEOMETRY', `layer ${layerId}: object without objectId skipped`, { layerId }));
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
const v = validateGeometry(o.geometry);
|
|
1592
|
+
if (!v.valid) {
|
|
1593
|
+
this.emitter.emit('warning', new MapEngineError('INVALID_GEOMETRY', `${o.objectId}: ${v.reason}`, { layerId, objectId: o.objectId }));
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
if (seen.has(o.objectId))
|
|
1597
|
+
continue;
|
|
1598
|
+
seen.add(o.objectId);
|
|
1599
|
+
this.checkSkew(o, layerId);
|
|
1600
|
+
out.push(o);
|
|
1601
|
+
}
|
|
1602
|
+
return out;
|
|
1603
|
+
}
|
|
1604
|
+
/** Reports, once per object, an observation time ahead of this clock beyond the tolerance (doc 14). */
|
|
1605
|
+
checkSkew(o, layerId) {
|
|
1606
|
+
if (!o.observedAt || this.skewed.has(o.objectId))
|
|
1607
|
+
return;
|
|
1608
|
+
const ahead = Date.parse(o.observedAt) - this.now();
|
|
1609
|
+
if (!(ahead > this.skewToleranceMs))
|
|
1610
|
+
return;
|
|
1611
|
+
this.skewed.add(o.objectId);
|
|
1612
|
+
this.emitter.emit('warning', new MapEngineError('CLOCK_SKEW', `${o.objectId}: observedAt ${o.observedAt} is ${Math.round(ahead / 1000)} s ahead of this clock`, { layerId, objectId: o.objectId, observedAt: o.observedAt, aheadMs: ahead }));
|
|
1613
|
+
}
|
|
1614
|
+
rebuildIndex() {
|
|
1615
|
+
this.index.clear();
|
|
1616
|
+
for (const [layerId, objects] of this.displayed) {
|
|
1617
|
+
const layer = this.layers.get(layerId);
|
|
1618
|
+
// Trails share their object's id and links are drawn between objects: derived, not objects. Keep them out.
|
|
1619
|
+
if (!layer || isDerived(layer))
|
|
1620
|
+
continue;
|
|
1621
|
+
for (const o of objects)
|
|
1622
|
+
this.index.insert(o);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
// ---------------------------------------------------------------- measuring and references (doc 28 G8, G9)
|
|
1626
|
+
measure(geometry) {
|
|
1627
|
+
const v = validateGeometry(geometry);
|
|
1628
|
+
if (!v.valid)
|
|
1629
|
+
throw new MapEngineError('INVALID_GEOMETRY', v.reason);
|
|
1630
|
+
return measureGeometry(geometry);
|
|
1631
|
+
}
|
|
1632
|
+
async resolveReference(objectId) {
|
|
1633
|
+
this.assertAlive();
|
|
1634
|
+
const o = this.lookup(objectId);
|
|
1635
|
+
// Shown = drawn by an application layer (an engine halo or decoration does not count).
|
|
1636
|
+
const drawn = this.scene.getState().objects.some((so) => so.object.objectId === objectId && !so.layerId.startsWith('__'));
|
|
1637
|
+
if (o)
|
|
1638
|
+
return { status: drawn || this.annotationStore.has(objectId) || this.proposals.has(objectId) ? 'shown' : 'loaded', object: structuredClone(o) };
|
|
1639
|
+
try {
|
|
1640
|
+
const found = (await this.deps.provider.getCurrentState([objectId])).find((x) => x?.objectId === objectId);
|
|
1641
|
+
if (found && validateGeometry(found.geometry).valid)
|
|
1642
|
+
return { status: 'available', object: found };
|
|
1643
|
+
}
|
|
1644
|
+
catch {
|
|
1645
|
+
// A failed lookup and a missing object read the same to the person: not available to them.
|
|
1646
|
+
}
|
|
1647
|
+
return { status: 'unavailable' };
|
|
1648
|
+
}
|
|
1649
|
+
/** A loaded object, an annotation or a proposal, by id. */
|
|
1650
|
+
lookup(id) {
|
|
1651
|
+
const o = this.index.get(id);
|
|
1652
|
+
if (o)
|
|
1653
|
+
return o;
|
|
1654
|
+
const a = this.annotationStore.get(id);
|
|
1655
|
+
if (a)
|
|
1656
|
+
return { objectId: a.id, geometry: a.geometry, properties: { label: a.label ?? '', note: a.note ?? '', kind: a.kind, ...(a.author ? { author: a.author } : {}) } };
|
|
1657
|
+
const p = this.proposals.get(id);
|
|
1658
|
+
if (p)
|
|
1659
|
+
return { objectId: p.id, geometry: p.geometry, properties: { label: p.label ?? '', note: p.note ?? '', origin: p.origin } };
|
|
1660
|
+
return undefined;
|
|
1661
|
+
}
|
|
1662
|
+
// ---------------------------------------------------------------- identity and access (doc 28 G13)
|
|
1663
|
+
getLayerStatus(layerId) {
|
|
1664
|
+
return this.layerStatus.get(layerId);
|
|
1665
|
+
}
|
|
1666
|
+
setStatus(layerId, status, reason) {
|
|
1667
|
+
if (this.layerStatus.get(layerId) === status)
|
|
1668
|
+
return;
|
|
1669
|
+
this.layerStatus.set(layerId, status);
|
|
1670
|
+
this.emitter.emit('layerStatus', { layerId, status, ...(reason ? { reason } : {}) });
|
|
1671
|
+
}
|
|
1672
|
+
/** A layer refused for this user: its status says so, with one warning, and it is not counted as a failure. */
|
|
1673
|
+
denied(layerId, reason) {
|
|
1674
|
+
if (this.layerStatus.get(layerId) !== 'denied')
|
|
1675
|
+
this.emitter.emit('warning', new MapEngineError('ACCESS_DENIED', `layer ${layerId}: ${reason}`, { layerId }));
|
|
1676
|
+
this.setStatus(layerId, 'denied', reason);
|
|
1677
|
+
}
|
|
1678
|
+
setAuth(auth) {
|
|
1679
|
+
this.assertAlive();
|
|
1680
|
+
if (deepEqual(auth, this.auth))
|
|
1681
|
+
return;
|
|
1682
|
+
this.auth = auth ? structuredClone(auth) : null;
|
|
1683
|
+
this.handAuth();
|
|
1684
|
+
// What the user may see changed: everything is asked again with the new identity.
|
|
1685
|
+
this.bindings.clear();
|
|
1686
|
+
this.layerStatus.clear();
|
|
1687
|
+
if (this.mounted) {
|
|
1688
|
+
void this.loadAnnotations();
|
|
1689
|
+
this.refresh().catch((e) => this.emitter.emit('error', toEngineError(e)));
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
/** Hands the identity to every provider that accepts it (the engine itself never decides access). */
|
|
1693
|
+
handAuth() {
|
|
1694
|
+
const d = this.deps;
|
|
1695
|
+
for (const p of new Set([d.provider, d.realtime, d.history, d.ontology, d.elevation, d.links, d.journal, d.annotations]))
|
|
1696
|
+
if (isAuthAware(p))
|
|
1697
|
+
p.setAuth(this.auth ? structuredClone(this.auth) : null);
|
|
1698
|
+
}
|
|
1699
|
+
// ---------------------------------------------------------------- offline profile (doc 28 G14)
|
|
1700
|
+
assertOffline(parts) {
|
|
1701
|
+
const bad = offlineViolations(this.offline, parts);
|
|
1702
|
+
if (!bad.length)
|
|
1703
|
+
return;
|
|
1704
|
+
throw new MapEngineError('OFFLINE_VIOLATION', `offline profile: ${bad.map((b) => `${b.where} = ${b.url}`).join('; ')} would leave the site`, { violations: bad });
|
|
1705
|
+
}
|
|
1706
|
+
// ---------------------------------------------------------------- highlight and proposals (doc 28 G7)
|
|
1707
|
+
highlight(objectIds, options = {}) {
|
|
1708
|
+
this.assertAlive();
|
|
1709
|
+
if (!Array.isArray(objectIds) || !objectIds.every((x) => typeof x === 'string' && x))
|
|
1710
|
+
throw new MapEngineError('INVALID_COMMAND', 'highlight needs object ids');
|
|
1711
|
+
if (this.highlightTimer !== null)
|
|
1712
|
+
clearTimeout(this.highlightTimer);
|
|
1713
|
+
this.highlightTimer = null;
|
|
1714
|
+
const until = options.durationMs ? toIso(this.now() + options.durationMs) : undefined;
|
|
1715
|
+
this.highlighted = { objectIds: [...new Set(objectIds)], ...(options.color ? { color: options.color } : {}), ...(until ? { until } : {}), ...(options.origin ? { origin: options.origin } : {}) };
|
|
1716
|
+
if (options.durationMs)
|
|
1717
|
+
this.highlightTimer = setTimeout(() => this.clearHighlight(), options.durationMs);
|
|
1718
|
+
this.refreshOverlays();
|
|
1719
|
+
this.emitter.emit('highlight', this.getHighlight());
|
|
1720
|
+
}
|
|
1721
|
+
clearHighlight() {
|
|
1722
|
+
if (this.highlightTimer !== null)
|
|
1723
|
+
clearTimeout(this.highlightTimer);
|
|
1724
|
+
this.highlightTimer = null;
|
|
1725
|
+
if (!this.highlighted || this.current === 'destroyed')
|
|
1726
|
+
return;
|
|
1727
|
+
this.highlighted = null;
|
|
1728
|
+
this.refreshOverlays();
|
|
1729
|
+
this.emitter.emit('highlight', null);
|
|
1730
|
+
}
|
|
1731
|
+
getHighlight() {
|
|
1732
|
+
return this.highlighted ? structuredClone(this.highlighted) : null;
|
|
1733
|
+
}
|
|
1734
|
+
newId(prefix) {
|
|
1735
|
+
return `${prefix}-${this.now().toString(36)}-${(++this.idSeq).toString(36)}`;
|
|
1736
|
+
}
|
|
1737
|
+
propose(input) {
|
|
1738
|
+
this.assertAlive();
|
|
1739
|
+
const v = validateGeometry(input?.geometry);
|
|
1740
|
+
if (!v.valid)
|
|
1741
|
+
throw new MapEngineError('INVALID_GEOMETRY', `proposal: ${v.reason}`);
|
|
1742
|
+
const proposal = {
|
|
1743
|
+
id: input.id ?? this.newId('proposal'),
|
|
1744
|
+
geometry: structuredClone(input.geometry),
|
|
1745
|
+
...(input.label ? { label: input.label } : {}),
|
|
1746
|
+
...(input.note ? { note: input.note } : {}),
|
|
1747
|
+
origin: input.origin ?? 'user',
|
|
1748
|
+
createdAt: toIso(this.now()),
|
|
1749
|
+
};
|
|
1750
|
+
this.proposals.set(proposal.id, proposal);
|
|
1751
|
+
this.refreshOverlays();
|
|
1752
|
+
this.emitter.emit('proposal', { action: 'proposed', proposal: structuredClone(proposal) });
|
|
1753
|
+
return structuredClone(proposal);
|
|
1754
|
+
}
|
|
1755
|
+
async acceptProposal(id, options = {}) {
|
|
1756
|
+
this.assertAlive();
|
|
1757
|
+
const p = this.proposals.get(id);
|
|
1758
|
+
if (!p)
|
|
1759
|
+
throw new MapEngineError('INVALID_COMMAND', `no proposal ${id}`);
|
|
1760
|
+
const kind = p.geometry.type === 'Point' || p.geometry.type === 'MultiPoint' ? 'point' : p.geometry.type === 'Circle' ? 'circle' : p.geometry.type.includes('Line') ? 'line' : 'polygon';
|
|
1761
|
+
const by = options.by ?? 'user';
|
|
1762
|
+
const annotation = await this.addAnnotation({ kind, geometry: p.geometry, ...((options.label ?? p.label) ? { label: options.label ?? p.label } : {}), ...(p.note ? { note: p.note } : {}), author: by, metadata: { proposedBy: p.origin, proposalId: p.id } }, by);
|
|
1763
|
+
this.proposals.delete(id);
|
|
1764
|
+
this.refreshOverlays();
|
|
1765
|
+
this.emitter.emit('proposal', { action: 'accepted', proposal: structuredClone(p), by, annotationId: annotation.id });
|
|
1766
|
+
return annotation;
|
|
1767
|
+
}
|
|
1768
|
+
rejectProposal(id, options = {}) {
|
|
1769
|
+
this.assertAlive();
|
|
1770
|
+
const p = this.proposals.get(id);
|
|
1771
|
+
if (!p)
|
|
1772
|
+
return;
|
|
1773
|
+
this.proposals.delete(id);
|
|
1774
|
+
this.refreshOverlays();
|
|
1775
|
+
this.emitter.emit('proposal', { action: 'rejected', proposal: structuredClone(p), by: options.by ?? 'user', ...(options.reason ? { reason: options.reason } : {}) });
|
|
1776
|
+
}
|
|
1777
|
+
getProposals() {
|
|
1778
|
+
return [...this.proposals.values()].map((p) => structuredClone(p));
|
|
1779
|
+
}
|
|
1780
|
+
clearProposals(origin) {
|
|
1781
|
+
const gone = [...this.proposals.values()].filter((p) => origin === undefined || p.origin === origin);
|
|
1782
|
+
if (!gone.length)
|
|
1783
|
+
return;
|
|
1784
|
+
for (const p of gone)
|
|
1785
|
+
this.proposals.delete(p.id);
|
|
1786
|
+
this.refreshOverlays();
|
|
1787
|
+
for (const p of gone)
|
|
1788
|
+
this.emitter.emit('proposal', { action: 'cleared', proposal: structuredClone(p) });
|
|
1789
|
+
}
|
|
1790
|
+
// ---------------------------------------------------------------- annotations (doc 29 §5, doc 28 G10)
|
|
1791
|
+
/** Annotations kept by deps.annotations, and changes others make to them. */
|
|
1792
|
+
async loadAnnotations() {
|
|
1793
|
+
const store = this.deps.annotations;
|
|
1794
|
+
if (!store)
|
|
1795
|
+
return;
|
|
1796
|
+
this.annotationSub?.();
|
|
1797
|
+
this.annotationSub = null;
|
|
1798
|
+
try {
|
|
1799
|
+
const list = await store.list();
|
|
1800
|
+
if (this.current === 'destroyed')
|
|
1801
|
+
return;
|
|
1802
|
+
this.annotationStore.clear();
|
|
1803
|
+
for (const a of list)
|
|
1804
|
+
if (a && typeof a.id === 'string' && validateGeometry(a.geometry).valid)
|
|
1805
|
+
this.annotationStore.set(a.id, structuredClone(a));
|
|
1806
|
+
this.refreshOverlays();
|
|
1807
|
+
}
|
|
1808
|
+
catch (e) {
|
|
1809
|
+
if (isDenied(e))
|
|
1810
|
+
this.warnOnce('annotations-denied', `annotations: ${errorMessage(e)}`, 'ACCESS_DENIED');
|
|
1811
|
+
else
|
|
1812
|
+
this.emitter.emit('error', new MapEngineError('PROVIDER_ERROR', `annotations: ${errorMessage(e)}`, undefined, { cause: e }));
|
|
1813
|
+
}
|
|
1814
|
+
if (store.subscribe && this.mounted) {
|
|
1815
|
+
this.annotationSub = store.subscribe((change) => {
|
|
1816
|
+
if (this.current === 'destroyed')
|
|
1817
|
+
return;
|
|
1818
|
+
for (const a of change.upserted ?? [])
|
|
1819
|
+
if (a && validateGeometry(a.geometry).valid)
|
|
1820
|
+
this.annotationStore.set(a.id, structuredClone(a));
|
|
1821
|
+
for (const id of change.removed ?? [])
|
|
1822
|
+
this.annotationStore.delete(id);
|
|
1823
|
+
this.refreshOverlays();
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
checkAnnotation(a) {
|
|
1828
|
+
if (!ANNOTATION_KINDS.includes(a.kind))
|
|
1829
|
+
throw new MapEngineError('INVALID_GEOMETRY', `annotation kind must be one of ${ANNOTATION_KINDS.join(', ')}`);
|
|
1830
|
+
const v = validateGeometry(a.geometry);
|
|
1831
|
+
if (!v.valid)
|
|
1832
|
+
throw new MapEngineError('INVALID_GEOMETRY', `annotation: ${v.reason}`);
|
|
1833
|
+
}
|
|
1834
|
+
/** Saves through deps.annotations; without one the annotation stays in this runtime (reported once). */
|
|
1835
|
+
async persist(a) {
|
|
1836
|
+
const store = this.deps.annotations;
|
|
1837
|
+
if (!store) {
|
|
1838
|
+
this.warnOnce('annotations', 'annotations are kept in this map only: sharing them needs an annotation store (deps.annotations)', 'MISSING_CAPABILITY');
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
try {
|
|
1842
|
+
await store.save(structuredClone(a));
|
|
1843
|
+
}
|
|
1844
|
+
catch (e) {
|
|
1845
|
+
throw new MapEngineError(isDenied(e) ? 'ACCESS_DENIED' : 'PROVIDER_ERROR', `annotation ${a.id} could not be saved: ${errorMessage(e)}`, { annotationId: a.id }, { cause: e });
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
async addAnnotation(input, origin = 'user') {
|
|
1849
|
+
this.assertAlive();
|
|
1850
|
+
this.checkAnnotation(input);
|
|
1851
|
+
const a = { ...structuredClone(input), id: input.id ?? this.newId('annotation'), createdAt: toIso(this.now()) };
|
|
1852
|
+
await this.persist(a);
|
|
1853
|
+
if (this.current === 'destroyed')
|
|
1854
|
+
return structuredClone(a);
|
|
1855
|
+
this.annotationStore.set(a.id, a);
|
|
1856
|
+
this.refreshOverlays();
|
|
1857
|
+
this.emitter.emit('annotation', { action: 'created', id: a.id, annotation: structuredClone(a), origin });
|
|
1858
|
+
return structuredClone(a);
|
|
1859
|
+
}
|
|
1860
|
+
async updateAnnotation(id, patch, origin = 'user') {
|
|
1861
|
+
this.assertAlive();
|
|
1862
|
+
const cur = this.annotationStore.get(id);
|
|
1863
|
+
if (!cur)
|
|
1864
|
+
throw new MapEngineError('INVALID_COMMAND', `no annotation ${id}`);
|
|
1865
|
+
const next = { ...cur, ...structuredClone(patch), id, updatedAt: toIso(this.now()) };
|
|
1866
|
+
this.checkAnnotation(next);
|
|
1867
|
+
await this.persist(next);
|
|
1868
|
+
this.annotationStore.set(id, next);
|
|
1869
|
+
this.refreshOverlays();
|
|
1870
|
+
this.emitter.emit('annotation', { action: 'updated', id, annotation: structuredClone(next), origin });
|
|
1871
|
+
return structuredClone(next);
|
|
1872
|
+
}
|
|
1873
|
+
async removeAnnotation(id, origin = 'user') {
|
|
1874
|
+
this.assertAlive();
|
|
1875
|
+
if (!this.annotationStore.has(id))
|
|
1876
|
+
return;
|
|
1877
|
+
if (this.deps.annotations) {
|
|
1878
|
+
try {
|
|
1879
|
+
await this.deps.annotations.remove(id);
|
|
1880
|
+
}
|
|
1881
|
+
catch (e) {
|
|
1882
|
+
throw new MapEngineError(isDenied(e) ? 'ACCESS_DENIED' : 'PROVIDER_ERROR', `annotation ${id} could not be removed: ${errorMessage(e)}`, { annotationId: id }, { cause: e });
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
this.annotationStore.delete(id);
|
|
1886
|
+
if (this.selection.get().objectIds.includes(id))
|
|
1887
|
+
this.clearSelection('api');
|
|
1888
|
+
this.refreshOverlays();
|
|
1889
|
+
this.emitter.emit('annotation', { action: 'removed', id, origin });
|
|
1890
|
+
}
|
|
1891
|
+
setAnnotationsVisible(visible) {
|
|
1892
|
+
this.assertAlive();
|
|
1893
|
+
if (visible === this.annotationsVisible)
|
|
1894
|
+
return;
|
|
1895
|
+
this.annotationsVisible = visible;
|
|
1896
|
+
this.refreshOverlays();
|
|
1897
|
+
}
|
|
1898
|
+
getAnnotations() {
|
|
1899
|
+
return [...this.annotationStore.values()].sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? '')).map((a) => structuredClone(a));
|
|
1900
|
+
}
|
|
1901
|
+
// ---------------------------------------------------------------- commands (doc 28 G6)
|
|
1902
|
+
async execute(commands, options = {}) {
|
|
1903
|
+
this.assertAlive();
|
|
1904
|
+
const list = Array.isArray(commands) ? commands : [commands];
|
|
1905
|
+
const origin = options.origin ?? 'user';
|
|
1906
|
+
const finish = (result) => {
|
|
1907
|
+
this.emitter.emit('command', { commands: structuredClone(list), origin, result });
|
|
1908
|
+
return result;
|
|
1909
|
+
};
|
|
1910
|
+
// The whole list is checked before anything runs: refused whole, never half-applied.
|
|
1911
|
+
for (const [i, c] of list.entries()) {
|
|
1912
|
+
const problem = commandProblem(c) ?? this.commandReferenceProblem(c);
|
|
1913
|
+
if (problem)
|
|
1914
|
+
return finish({ ok: false, applied: 0, error: { index: i, reason: problem } });
|
|
1915
|
+
}
|
|
1916
|
+
const results = {};
|
|
1917
|
+
for (const [i, c] of list.entries()) {
|
|
1918
|
+
try {
|
|
1919
|
+
const r = await this.runCommand(c, origin);
|
|
1920
|
+
if (r !== undefined)
|
|
1921
|
+
results[i] = r;
|
|
1922
|
+
}
|
|
1923
|
+
catch (e) {
|
|
1924
|
+
return finish({ ok: false, applied: i, error: { index: i, reason: errorMessage(e) }, ...(Object.keys(results).length ? { results } : {}) });
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
return finish({ ok: true, applied: list.length, ...(Object.keys(results).length ? { results } : {}) });
|
|
1928
|
+
}
|
|
1929
|
+
/** What only the runtime can check before running: layers exist, conditions and geometries are valid. */
|
|
1930
|
+
commandReferenceProblem(c) {
|
|
1931
|
+
switch (c.type) {
|
|
1932
|
+
case 'showLayer':
|
|
1933
|
+
case 'hideLayer':
|
|
1934
|
+
return this.layers.has(c.layerId) ? null : `unknown layer "${c.layerId}"`;
|
|
1935
|
+
case 'setFilter': {
|
|
1936
|
+
if (!this.layers.has(c.layerId))
|
|
1937
|
+
return `unknown layer "${c.layerId}"`;
|
|
1938
|
+
if (!c.where.length)
|
|
1939
|
+
return null;
|
|
1940
|
+
const v = validateConditions(c.where, 'where');
|
|
1941
|
+
return v.valid ? null : v.reason;
|
|
1942
|
+
}
|
|
1943
|
+
case 'fitTo':
|
|
1944
|
+
if (c.bounds) {
|
|
1945
|
+
const b = c.bounds;
|
|
1946
|
+
const ok = [b.west, b.south, b.east, b.north].every((n) => typeof n === 'number' && Number.isFinite(n)) && b.west <= b.east && b.south <= b.north;
|
|
1947
|
+
return ok ? null : 'bounds must have west <= east and south <= north';
|
|
1948
|
+
}
|
|
1949
|
+
return null;
|
|
1950
|
+
case 'drawProposal': {
|
|
1951
|
+
const v = validateGeometry(c.geometry);
|
|
1952
|
+
return v.valid ? null : v.reason;
|
|
1953
|
+
}
|
|
1954
|
+
default:
|
|
1955
|
+
return null;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
/** One command, through the runtime's public methods only: the command set adds no behaviour of its own. */
|
|
1959
|
+
async runCommand(c, origin) {
|
|
1960
|
+
switch (c.type) {
|
|
1961
|
+
case 'showLayer':
|
|
1962
|
+
return this.setLayerVisibility(c.layerId, true);
|
|
1963
|
+
case 'hideLayer':
|
|
1964
|
+
return this.setLayerVisibility(c.layerId, false);
|
|
1965
|
+
case 'setFilter': {
|
|
1966
|
+
const { where: _w, ...rest } = this.layers.get(c.layerId).source;
|
|
1967
|
+
return this.updateLayer(c.layerId, { source: c.where.length ? { ...rest, where: c.where } : rest });
|
|
1968
|
+
}
|
|
1969
|
+
case 'flyTo': {
|
|
1970
|
+
const ok = await this.flyTo(c.objectId ?? c.position, c.zoom !== undefined ? { zoom: c.zoom } : undefined);
|
|
1971
|
+
if (!ok)
|
|
1972
|
+
throw new Error(`object ${c.objectId} is not available`);
|
|
1973
|
+
return undefined;
|
|
1974
|
+
}
|
|
1975
|
+
case 'fitTo': {
|
|
1976
|
+
let bounds = c.bounds;
|
|
1977
|
+
if (!bounds) {
|
|
1978
|
+
const missing = c.objectIds.filter((id) => !this.lookup(id));
|
|
1979
|
+
const fetched = missing.length ? await this.deps.provider.getCurrentState(missing) : [];
|
|
1980
|
+
const geoms = [...c.objectIds.map((id) => this.lookup(id)).filter((o) => !!o), ...fetched.filter((o) => o && validateGeometry(o.geometry).valid)].map((o) => geometryBounds(o.geometry));
|
|
1981
|
+
bounds = unionBounds(geoms);
|
|
1982
|
+
if (!bounds)
|
|
1983
|
+
throw new Error('none of these objects is available');
|
|
1984
|
+
}
|
|
1985
|
+
return this.fitBounds(bounds, { padding: 40, animate: true });
|
|
1986
|
+
}
|
|
1987
|
+
case 'select':
|
|
1988
|
+
return c.objectIds.length ? this.setSelection({ objectIds: c.objectIds, source: origin }) : this.clearSelection(origin);
|
|
1989
|
+
case 'clearSelection':
|
|
1990
|
+
return this.clearSelection(origin);
|
|
1991
|
+
case 'highlight':
|
|
1992
|
+
return this.highlight(c.objectIds, { ...(c.durationMs ? { durationMs: c.durationMs } : {}), ...(c.color ? { color: c.color } : {}), origin });
|
|
1993
|
+
case 'clearHighlight':
|
|
1994
|
+
return this.clearHighlight();
|
|
1995
|
+
case 'openInspector':
|
|
1996
|
+
return void this.emitter.emit('inspectRequested', { objectId: c.objectId, origin });
|
|
1997
|
+
case 'setTime':
|
|
1998
|
+
return this.setTime(c.at === 'live' ? { mode: 'live' } : { mode: 'replay', at: c.at });
|
|
1999
|
+
case 'play':
|
|
2000
|
+
return void this.emitter.emit('playbackRequested', { action: 'play', ...(c.speed ? { speed: c.speed } : {}), origin });
|
|
2001
|
+
case 'pause':
|
|
2002
|
+
return void this.emitter.emit('playbackRequested', { action: 'pause', origin });
|
|
2003
|
+
case 'drawProposal':
|
|
2004
|
+
return this.propose({ geometry: c.geometry, ...(c.label ? { label: c.label } : {}), ...(c.note ? { note: c.note } : {}), ...(c.id ? { id: c.id } : {}), origin }).id;
|
|
2005
|
+
case 'clearProposals':
|
|
2006
|
+
return this.clearProposals();
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
async getJournal(query, range, signal) {
|
|
2010
|
+
this.assertAlive();
|
|
2011
|
+
if (!this.deps.journal)
|
|
2012
|
+
throw new MapEngineError('MISSING_CAPABILITY', 'the event journal needs a journal provider (deps.journal)');
|
|
2013
|
+
return this.deps.journal.getJournal(structuredClone(query), { ...range }, signal);
|
|
2014
|
+
}
|
|
2015
|
+
warnOnce(key, message, code = 'MISSING_CAPABILITY') {
|
|
2016
|
+
if (this.warned.has(key))
|
|
2017
|
+
return;
|
|
2018
|
+
this.warned.add(key);
|
|
2019
|
+
this.emitter.emit('warning', new MapEngineError(code, message));
|
|
2020
|
+
}
|
|
2021
|
+
// ---------------------------------------------------------------- plumbing
|
|
2022
|
+
push(patch) {
|
|
2023
|
+
if (isEmptyPatch(patch))
|
|
2024
|
+
return;
|
|
2025
|
+
if (this.mounted)
|
|
2026
|
+
this.renderer.update(patch);
|
|
2027
|
+
this.emitter.emit('scene', patch);
|
|
2028
|
+
}
|
|
2029
|
+
schedule() {
|
|
2030
|
+
if (!this.mounted || this.current === 'destroyed')
|
|
2031
|
+
return;
|
|
2032
|
+
this.cancelScheduled();
|
|
2033
|
+
this.timer = setTimeout(() => {
|
|
2034
|
+
this.timer = null;
|
|
2035
|
+
this.refresh().catch((e) => this.emitter.emit('error', toEngineError(e)));
|
|
2036
|
+
}, this.debounceMs);
|
|
2037
|
+
}
|
|
2038
|
+
cancelScheduled() {
|
|
2039
|
+
if (this.timer !== null) {
|
|
2040
|
+
clearTimeout(this.timer);
|
|
2041
|
+
this.timer = null;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
size() {
|
|
2045
|
+
const s = this.mounted ? this.renderer.getSize() : { width: 0, height: 0 };
|
|
2046
|
+
return s.width > 0 && s.height > 0 ? s : this.fallbackSize;
|
|
2047
|
+
}
|
|
2048
|
+
/** Renderer-reported bounds win; otherwise compute from Web Mercator. */
|
|
2049
|
+
withBounds(v) {
|
|
2050
|
+
return v.bounds ? v : { ...v, bounds: computeBounds(v, this.size()) };
|
|
2051
|
+
}
|
|
2052
|
+
setState(s) {
|
|
2053
|
+
if (this.current === s)
|
|
2054
|
+
return;
|
|
2055
|
+
this.current = s;
|
|
2056
|
+
this.emitter.emit('state', s);
|
|
2057
|
+
}
|
|
2058
|
+
assertAlive() {
|
|
2059
|
+
if (this.current === 'destroyed')
|
|
2060
|
+
throw new MapEngineError('DESTROYED', 'runtime has been destroyed');
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
/** Camera equality with tolerance for float noise from the renderer; bounds are ignored. */
|
|
2064
|
+
/** Tilesets: unique ids and a URL each. */
|
|
2065
|
+
function validateTilesets(list) {
|
|
2066
|
+
if (!Array.isArray(list))
|
|
2067
|
+
return { valid: false, reason: 'tilesets must be an array' };
|
|
2068
|
+
const ids = new Set();
|
|
2069
|
+
for (const t of list) {
|
|
2070
|
+
if (!t || typeof t.id !== 'string' || !t.id)
|
|
2071
|
+
return { valid: false, reason: 'every tileset needs an id' };
|
|
2072
|
+
if (ids.has(t.id))
|
|
2073
|
+
return { valid: false, reason: `tileset ${t.id} appears twice` };
|
|
2074
|
+
ids.add(t.id);
|
|
2075
|
+
if (typeof t.url !== 'string' || !t.url)
|
|
2076
|
+
return { valid: false, reason: `tileset ${t.id}: url is required` };
|
|
2077
|
+
if (t.maximumScreenSpaceError !== undefined && !(t.maximumScreenSpaceError > 0))
|
|
2078
|
+
return { valid: false, reason: `tileset ${t.id}: maximumScreenSpaceError must be > 0` };
|
|
2079
|
+
}
|
|
2080
|
+
return { valid: true };
|
|
2081
|
+
}
|
|
2082
|
+
/** Terrain config: a DEM tile URL and sane numbers (the engine runs no terrain service, §25). */
|
|
2083
|
+
function validateTerrain(t) {
|
|
2084
|
+
if (!t || typeof t.tileUrl !== 'string' || !t.tileUrl.includes('{z}'))
|
|
2085
|
+
return { valid: false, reason: 'terrain.tileUrl must be a {z}/{x}/{y} URL of elevation tiles' };
|
|
2086
|
+
if (t.encoding !== undefined && t.encoding !== 'terrarium' && t.encoding !== 'mapbox')
|
|
2087
|
+
return { valid: false, reason: 'terrain.encoding must be terrarium or mapbox' };
|
|
2088
|
+
if (t.exaggeration !== undefined && !(typeof t.exaggeration === 'number' && t.exaggeration > 0))
|
|
2089
|
+
return { valid: false, reason: 'terrain.exaggeration must be > 0' };
|
|
2090
|
+
return { valid: true };
|
|
2091
|
+
}
|
|
2092
|
+
function sameCamera(a, b) {
|
|
2093
|
+
const eps = 1e-9;
|
|
2094
|
+
return (Math.abs(a.center.latitude - b.center.latitude) < eps &&
|
|
2095
|
+
Math.abs(a.center.longitude - b.center.longitude) < eps &&
|
|
2096
|
+
Math.abs(a.zoom - b.zoom) < eps &&
|
|
2097
|
+
Math.abs((a.bearing ?? 0) - (b.bearing ?? 0)) < eps &&
|
|
2098
|
+
Math.abs((a.pitch ?? 0) - (b.pitch ?? 0)) < eps);
|
|
2099
|
+
}
|
|
2100
|
+
/** True when `next` was observed before `prev` (both timestamps known). Equal timestamps apply (idempotent). */
|
|
2101
|
+
const errorMessage = (e) => (e instanceof Error ? e.message : String(e));
|
|
2102
|
+
const toEngineError = (e) => (e instanceof MapEngineError ? e : new MapEngineError('PROVIDER_ERROR', errorMessage(e), undefined, { cause: e }));
|
|
2103
|
+
export function createMapRuntime(config, deps, options) {
|
|
2104
|
+
return new Runtime(config, deps, options);
|
|
2105
|
+
}
|