mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6521fd2316 | ||
|
|
346a08a42e |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- Cut the stutter while zooming and panning with Street Traffic on in dense
|
||||
cities. Coarse roads are no longer clipped against every detail tile in one
|
||||
task, roads already parsed in a load are not parsed again, and ground floors
|
||||
are read once per cell (mazy1998, #1705).
|
||||
@@ -1,3 +0,0 @@
|
||||
- Shorten the freeze when Submarine cables is switched on from about 1.4 s to
|
||||
about 0.2 s. Landing markers are now shown a few dozen per frame instead of
|
||||
all at once (mazy1998, #1707).
|
||||
@@ -1,4 +0,0 @@
|
||||
- Submarine cables draw from a few batched primitives instead of thousands of
|
||||
entities. Switching the layer off and back on, and zooming with it on, no
|
||||
longer stall the page, and it holds about 900 MB less memory. Double-click
|
||||
no longer starts Cesium's entity tracking on a cable (mazy1998, #1722).
|
||||
+9
-31
@@ -1541,14 +1541,12 @@ query. Movement within an accepted or pending query reuses it. Markers retain
|
||||
their Cesium identity while their geometry is unchanged; refreshes preserve
|
||||
selection without replaying a click event.
|
||||
|
||||
Data Centers and Dams release their built Cesium data sources
|
||||
Data Centers, Dams and Submarine Cables release their built Cesium data sources
|
||||
and record references when disabled. Parsed datasets remain cached for the layer
|
||||
lifetime, so re-enable rebuilds entities without downloading or parsing again;
|
||||
this can take longer than simply revealing hidden entities. Destruction clears
|
||||
the parsed cache as well. A disable during loading cannot leave a completed
|
||||
build hidden in the scene. Submarine Cables draws with its own primitives
|
||||
instead of entities and only hides them when disabled (see "Bundled geography
|
||||
and submarine cable components").
|
||||
build hidden in the scene.
|
||||
|
||||
CCTV exposes a factory through `./layers/cctv`. Catalog and health requests,
|
||||
frame/media URLs, camera records, ground placement, geometry queues, playback,
|
||||
@@ -8149,37 +8147,17 @@ an error. Without feeds it keeps the WebUSB statuses. See
|
||||
Submarine cables use separate source, geometry, rendering, interaction and
|
||||
lifecycle modules. The layer factory accepts cable and landing-point GeoJSON
|
||||
collections from a source with `fetch(signal)` and a display label. The default
|
||||
source loads the same bundled TeleGeography files. Load ownership prevents
|
||||
cancelled work from adding anything to the scene after teardown: the fetch is
|
||||
the load's only await, and everything is built in the turn after it.
|
||||
source loads the same bundled TeleGeography files. Disabling still removes all
|
||||
three Cesium data sources; enabling rebuilds from the accepted parsed cache,
|
||||
and destroying clears it. Load ownership prevents cancelled work from adding
|
||||
entities after teardown.
|
||||
|
||||
The layer draws with four Cesium primitives of its own and creates no entity
|
||||
or data source. Every line is one instance of a single
|
||||
`GroundPolylinePrimitive` (per-line colour and show flag, rhumb lines,
|
||||
tessellated in a worker; a map-stack change re-classifies it in place). Every
|
||||
point is a billboard in one `BillboardCollection`. Reference tips are one
|
||||
`PointPrimitiveCollection`, and the stems of the references in view are one
|
||||
static polyline `Primitive`: a horizon sweep that moved a tip or changed which
|
||||
references are in view builds a replacement in a worker, and it replaces the
|
||||
shown stems once it is ready. Disabling hides the lines, markers and tips and
|
||||
keeps them, so enabling again neither fetches nor builds them (only the stems
|
||||
are redrawn, for the new view); destroying removes everything. These are
|
||||
the same primitives Cesium built from the layer's entities, so the layer looks
|
||||
the same. Polygon geometry in a custom source is not drawn, and GeoJSON
|
||||
simplestyle properties (`marker-color`, `stroke` and the like) are not read.
|
||||
|
||||
Every line and marker from multipart cable or landing-point geometry stays tied
|
||||
to its source feature. Each part gets its own feature's styling, click
|
||||
Every Cesium entity from multipart cable or landing-point geometry stays tied
|
||||
to its source feature, including generated IDs and IDs that collide with
|
||||
Cesium's multipart suffixes. Each segment gets its own feature's styling, click
|
||||
destination and horizon-culling reference, and each source feature has one
|
||||
reference stem. The source collections are not modified.
|
||||
|
||||
Landing markers are ground-clamped billboards. A marker starts hidden and
|
||||
unclamped; the horizon sweep clamps and shows the visible ones 48 per rendered
|
||||
frame (`CABLE_LANDING_REVEALS_PER_FRAME`), so the terrain height reads are
|
||||
never one task. A marker is clamped once: Cesium then keeps it on the surface
|
||||
as terrain loads, and hiding or showing it again reads nothing; hiding is
|
||||
immediate.
|
||||
|
||||
Natural Earth regions and neighborhood polygon lookup are package exports.
|
||||
Their existing lazy loaders, retry behavior, bundled datasets and attribution
|
||||
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
createCableOverlayEntry,
|
||||
cableClassificationTypeForStack,
|
||||
cableClassificationTypeForScene,
|
||||
applyTranslucentMarkerBlend,
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
} from '../layers/submarineCables/index.js';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -77,58 +77,3 @@ export function normalizeFeatures(json, kind) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function partPosition(value) {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
const height = Number(value[2]);
|
||||
return Number.isFinite(height) ? [lon, lat, height] : [lon, lat];
|
||||
}
|
||||
|
||||
function collectParts(geometry, out) {
|
||||
const coordinates = geometry?.coordinates;
|
||||
switch (geometry?.type) {
|
||||
case 'Point': {
|
||||
const point = partPosition(coordinates);
|
||||
if (point) out.points.push(point);
|
||||
break;
|
||||
}
|
||||
case 'MultiPoint':
|
||||
for (const value of coordinates || []) {
|
||||
const point = partPosition(value);
|
||||
if (point) out.points.push(point);
|
||||
}
|
||||
break;
|
||||
case 'LineString': {
|
||||
const line = (coordinates || []).map(partPosition).filter(Boolean);
|
||||
if (line.length >= 2) out.lines.push(line);
|
||||
break;
|
||||
}
|
||||
case 'MultiLineString':
|
||||
for (const part of coordinates || []) {
|
||||
const line = (part || []).map(partPosition).filter(Boolean);
|
||||
if (line.length >= 2) out.lines.push(line);
|
||||
}
|
||||
break;
|
||||
case 'GeometryCollection':
|
||||
for (const child of geometry.geometries || []) collectParts(child, out);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a feature the layer draws: every line of a LineString or
|
||||
* MultiLineString and every point of a Point or MultiPoint, also inside a
|
||||
* GeometryCollection. Each is [lon, lat] or [lon, lat, height] in degrees
|
||||
* and metres. Polygons are not drawn.
|
||||
* @returns {{lines:number[][][],points:number[][]}}
|
||||
*/
|
||||
export function featureParts(feature) {
|
||||
const out = { lines: [], points: [] };
|
||||
collectParts(feature?.geometry, out);
|
||||
return out;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export function createSubmarineCableLayer({
|
||||
screenSpaceEventHandlerFactory,
|
||||
mapStackEventTarget = null,
|
||||
sweepClock = defaultSweepClock,
|
||||
createMarkerImage,
|
||||
}) {
|
||||
if (!source?.fetch || !source.label)
|
||||
throw new TypeError(
|
||||
@@ -24,7 +23,6 @@ export function createSubmarineCableLayer({
|
||||
source,
|
||||
screenSpaceEventHandlerFactory,
|
||||
mapStackEventTarget,
|
||||
createMarkerImage,
|
||||
};
|
||||
parts.rendering = createRendering(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
@@ -42,6 +40,7 @@ export {
|
||||
export {
|
||||
cableClassificationTypeForStack,
|
||||
cableClassificationTypeForScene,
|
||||
applyTranslucentMarkerBlend,
|
||||
} from './surface.js';
|
||||
export {
|
||||
CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
|
||||
@@ -1,82 +1,27 @@
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import {
|
||||
featureLabel,
|
||||
featureParts,
|
||||
featureReference,
|
||||
normalizeFeatures,
|
||||
} from './geometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const toCartesian = ([lon, lat, height]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, height ?? 0);
|
||||
const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex';
|
||||
|
||||
function indexedCollection(features) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: features.map((feature, index) => ({
|
||||
...feature,
|
||||
properties: {
|
||||
...feature.properties,
|
||||
[SOURCE_FEATURE_INDEX]: index,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createIngestion({ state, parts, source }) {
|
||||
/**
|
||||
* Turn the two feature collections into what rendering draws. Every part
|
||||
* of a multipart feature is its own line or marker with its own pick
|
||||
* record, all sharing the feature's reference; a feature gets one
|
||||
* reference stem however many parts it has.
|
||||
*/
|
||||
function describeScene(cableFeatures, landingFeatures) {
|
||||
const lines = [];
|
||||
const markers = [];
|
||||
const references = [];
|
||||
const add = (features, kind, style) => {
|
||||
for (const feature of features) {
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) continue;
|
||||
const label = featureLabel(feature);
|
||||
const base = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
reference.lat,
|
||||
0,
|
||||
);
|
||||
const drawn = featureParts(feature);
|
||||
const pick = () =>
|
||||
parts.interaction.registerPick({ kind, reference, label });
|
||||
for (const line of drawn.lines)
|
||||
lines.push({
|
||||
info: pick(),
|
||||
base,
|
||||
positions: line.map(toCartesian),
|
||||
color: style.lineColor(feature),
|
||||
width: style.lineWidth,
|
||||
});
|
||||
for (const point of drawn.points)
|
||||
markers.push({
|
||||
info: pick(),
|
||||
base,
|
||||
position: toCartesian(point),
|
||||
hasHeight: point.length > 2,
|
||||
color: style.color,
|
||||
});
|
||||
references.push({
|
||||
reference,
|
||||
label,
|
||||
kind,
|
||||
color: style.color,
|
||||
feature,
|
||||
});
|
||||
}
|
||||
};
|
||||
add(cableFeatures, 'cable', {
|
||||
color: state.cableColor,
|
||||
lineWidth: 2.5,
|
||||
lineColor: (feature) => {
|
||||
const css = feature?.properties?.color;
|
||||
const color = css && Cesium.Color.fromCssColorString(String(css));
|
||||
return (color || state.cableColor).withAlpha(0.92);
|
||||
},
|
||||
});
|
||||
const landingLine = state.landingColor.withAlpha(0.9);
|
||||
add(landingFeatures, 'landing-point', {
|
||||
color: state.landingColor,
|
||||
lineWidth: 2,
|
||||
lineColor: () => landingLine,
|
||||
});
|
||||
return { lines, markers, references };
|
||||
}
|
||||
|
||||
async function load(viewer) {
|
||||
if (state._loading || state._loaded) return;
|
||||
|
||||
@@ -85,10 +30,10 @@ export function createIngestion({ state, parts, source }) {
|
||||
state._loadingLabel = t('loading...');
|
||||
// Ownership token (the militaryAwareness activationId pattern): a
|
||||
// disable/abort followed by a fresh enable starts a NEWER load while this
|
||||
// one is still settling. The stale load must bail after its await and
|
||||
// one is still settling. The stale load must bail after every await and
|
||||
// must never touch shared lifecycle state or the viewer it no longer
|
||||
// owns — clearing `_loading`/`_abort` for the successor is how duplicate
|
||||
// or post-destroy primitives would get added.
|
||||
// or post-destroy data sources got added.
|
||||
const generation = ++state._loadGeneration;
|
||||
const abort = new AbortController();
|
||||
state._abort = abort;
|
||||
@@ -96,34 +41,170 @@ export function createIngestion({ state, parts, source }) {
|
||||
generation === state._loadGeneration && !abort.signal.aborted;
|
||||
|
||||
try {
|
||||
const { cables: cableJson, landingPoints: landingJson } =
|
||||
await source.fetch(abort.signal);
|
||||
// The fetch is the only await: everything after it runs in this one
|
||||
// turn, so no disable or destroy can land between the ownership check
|
||||
// and the primitives reaching the scene.
|
||||
if (!owns()) return;
|
||||
if (
|
||||
!Array.isArray(cableJson?.features) ||
|
||||
!Array.isArray(landingJson?.features)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'A cable source must return cables and landingPoints GeoJSON collections',
|
||||
);
|
||||
let cableJson = state._cachedCableJson;
|
||||
let landingJson = state._cachedLandingJson;
|
||||
if (!cableJson || !landingJson) {
|
||||
({ cables: cableJson, landingPoints: landingJson } = await source.fetch(
|
||||
abort.signal,
|
||||
));
|
||||
if (!owns()) return;
|
||||
if (
|
||||
!Array.isArray(cableJson?.features) ||
|
||||
!Array.isArray(landingJson?.features)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'A cable source must return cables and landingPoints GeoJSON collections',
|
||||
);
|
||||
}
|
||||
state._cachedCableJson = cableJson;
|
||||
state._cachedLandingJson = landingJson;
|
||||
}
|
||||
|
||||
const cableFeatures = normalizeFeatures(cableJson, 'cable');
|
||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||
try {
|
||||
parts.rendering.buildScene(
|
||||
viewer,
|
||||
describeScene(cableFeatures, landingFeatures),
|
||||
);
|
||||
} catch (error) {
|
||||
// Leave nothing half built in the scene.
|
||||
parts.rendering.destroyScene(viewer);
|
||||
throw error;
|
||||
|
||||
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
indexedCollection(cableFeatures),
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.cableColor.withAlpha(0.95),
|
||||
fill: state.cableColor.withAlpha(0.18),
|
||||
strokeWidth: 2,
|
||||
markerColor: state.cableColor,
|
||||
markerSize: 6,
|
||||
},
|
||||
);
|
||||
if (!owns()) return;
|
||||
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
indexedCollection(landingFeatures),
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.landingColor.withAlpha(0.9),
|
||||
fill: state.landingColor.withAlpha(0.35),
|
||||
strokeWidth: 2,
|
||||
markerColor: state.landingColor,
|
||||
markerSize: 6,
|
||||
},
|
||||
);
|
||||
if (!owns()) return;
|
||||
|
||||
cableDataSource.name = `${source.label} Submarine Cables`;
|
||||
landingDataSource.name = `${source.label} Landing Points`;
|
||||
const referenceDataSource = new Cesium.CustomDataSource(
|
||||
`${source.label} Cable References`,
|
||||
);
|
||||
const addedSources = [
|
||||
cableDataSource,
|
||||
landingDataSource,
|
||||
referenceDataSource,
|
||||
];
|
||||
|
||||
// dataSources.add() is itself an await point: Cesium mutates the
|
||||
// collection on a DEFERRED tick, so a disable/destroy racing this
|
||||
// window finds nothing to remove and the adds still materialize
|
||||
// afterwards. Await every add to settlement (allSettled so a partial
|
||||
// failure cannot leave an unawaited add materializing later), then
|
||||
// re-check ownership. Pinned teardown mechanism: disable/destroy never
|
||||
// wait for in-flight adds — the stale generation's own post-await
|
||||
// check here guarantees cleanup by removing exactly the sources THIS
|
||||
// generation added.
|
||||
const addResults = await Promise.allSettled([
|
||||
viewer.dataSources.add(cableDataSource),
|
||||
viewer.dataSources.add(landingDataSource),
|
||||
viewer.dataSources.add(referenceDataSource),
|
||||
]);
|
||||
const rejectedAdd = addResults.find(
|
||||
(result) => result.status === 'rejected',
|
||||
);
|
||||
if (!owns() || rejectedAdd) {
|
||||
// Compensate: every add has settled by now, so removal is effective
|
||||
// (a remove inside the deferred window would have been a no-op).
|
||||
// Remove EVERY generation-local source regardless of settle status:
|
||||
// Cesium's add() pushes into the collection BEFORE raising
|
||||
// dataSourceAdded, so a REJECTED add may still have landed its
|
||||
// mutation. remove() of a never-added source is a harmless no-op.
|
||||
for (const source of addedSources) {
|
||||
try {
|
||||
viewer.dataSources.remove(source, true);
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
if (owns() && rejectedAdd) throw rejectedAdd.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
// Await-free commit: the viewer accepted all three sources and this
|
||||
// load still owns the lifecycle.
|
||||
state._cableDataSource = cableDataSource;
|
||||
state._landingDataSource = landingDataSource;
|
||||
state._referenceDataSource = referenceDataSource;
|
||||
|
||||
const cableEntities = state._cableDataSource.entities.values;
|
||||
const landingEntities = state._landingDataSource.entities.values;
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
|
||||
// Cesium expands multipart geometry into several entities and suffixes
|
||||
// colliding IDs. Its copied properties retain this source index even
|
||||
// when a provider has no ID, so every segment keeps the right owner.
|
||||
const cableReferences = new Set();
|
||||
cableEntities.forEach((entity) => {
|
||||
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
|
||||
const feature = cableFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
|
||||
parts.rendering.styleCableEntity(entity, feature);
|
||||
parts.interaction.registerPickEntity(entity, {
|
||||
kind: 'cable',
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
});
|
||||
state._surfaceRecords.push({
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
if (cableReferences.has(index)) return;
|
||||
cableReferences.add(index);
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
kind: 'cable',
|
||||
color: state.cableColor,
|
||||
feature,
|
||||
});
|
||||
});
|
||||
|
||||
const landingReferences = new Set();
|
||||
landingEntities.forEach((entity) => {
|
||||
const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue();
|
||||
const feature = landingFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
|
||||
parts.rendering.styleLandingEntity(entity, feature);
|
||||
parts.interaction.registerPickEntity(entity, {
|
||||
kind: 'landing-point',
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
});
|
||||
state._surfaceRecords.push({
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
if (landingReferences.has(index)) return;
|
||||
landingReferences.add(index);
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
kind: 'landing-point',
|
||||
color: state.landingColor,
|
||||
feature,
|
||||
});
|
||||
});
|
||||
|
||||
state._count = cableFeatures.length + landingFeatures.length;
|
||||
state._loaded = true;
|
||||
state._lastUpdate = Date.now();
|
||||
|
||||
@@ -2,10 +2,9 @@ import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
|
||||
/** Every drawn part carries its own record as the Cesium pick id. */
|
||||
function registerPick(info) {
|
||||
state._pickRecords.add(info);
|
||||
return info;
|
||||
function registerPickEntity(entity, info) {
|
||||
entity.__gevTeleGeography = info;
|
||||
state._pickByEntity.set(entity, info);
|
||||
}
|
||||
|
||||
function beginInteraction(viewer) {
|
||||
@@ -23,8 +22,32 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
|
||||
}
|
||||
|
||||
function resolvePickRecord(picked) {
|
||||
const info = picked?.id;
|
||||
return info && state._pickRecords.has(info) ? info : null;
|
||||
if (!picked) return null;
|
||||
const primitive = picked.primitive;
|
||||
if (primitive) {
|
||||
const primitiveInfo = state._pickByEntity.get(primitive);
|
||||
if (primitiveInfo) return primitiveInfo;
|
||||
if (primitive.__gevTeleGeography) return primitive.__gevTeleGeography;
|
||||
if (
|
||||
primitive.id &&
|
||||
typeof primitive.id === 'object' &&
|
||||
primitive.id.reference
|
||||
) {
|
||||
return primitive.id;
|
||||
}
|
||||
}
|
||||
|
||||
const entity = picked.id;
|
||||
if (entity) {
|
||||
const entityInfo = state._pickByEntity.get(entity);
|
||||
if (entityInfo) return entityInfo;
|
||||
if (entity.__gevTeleGeography) return entity.__gevTeleGeography;
|
||||
if (entity.id && typeof entity.id === 'object' && entity.id.reference) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function flyToReference(viewer, reference) {
|
||||
@@ -47,7 +70,7 @@ export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
|
||||
});
|
||||
}
|
||||
return {
|
||||
registerPick,
|
||||
registerPickEntity,
|
||||
beginInteraction,
|
||||
resolvePickRecord,
|
||||
flyToReference,
|
||||
|
||||
@@ -38,13 +38,12 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
|
||||
// never emit moveEnd — reach the motion fallback. No render is
|
||||
// requested for a fallback sweep: it only fires while the camera is
|
||||
// already moving, so frames are flowing by construction.
|
||||
if (!state._enabled || !state._loaded) return;
|
||||
// Markers left over from the last sweep, a few per frame; line
|
||||
// shows and rebuilt stems waiting for their geometry.
|
||||
parts.rendering.revealLandingMarkers();
|
||||
parts.rendering.syncLineShow();
|
||||
parts.rendering.settleStems();
|
||||
if (!state._referenceSweepGate.shouldRun(viewer.camera)) return;
|
||||
if (
|
||||
!state._enabled ||
|
||||
!state._loaded ||
|
||||
!state._referenceSweepGate.shouldRun(viewer.camera)
|
||||
)
|
||||
return;
|
||||
parts.rendering.updateReferenceVisibility();
|
||||
},
|
||||
);
|
||||
@@ -81,15 +80,10 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
|
||||
state._loading = false;
|
||||
state._loadingLabel = '';
|
||||
}
|
||||
// Hide what is built and keep it: a hidden primitive is skipped by
|
||||
// the scene, so it costs nothing per frame, and the next enable shows
|
||||
// it again without a rebuild. Only the stems go: they are sized for
|
||||
// the view and the next sweep redraws them in a worker. An in-flight load's own post-await
|
||||
// ownership check stops that stale generation from building anything.
|
||||
state._pendingLandingReveals.length = 0;
|
||||
parts.rendering.dropStems();
|
||||
parts.rendering.updateVisibility();
|
||||
state._viewer?.scene?.requestRender?.();
|
||||
// Free the entities rather than hide them; enable() rebuilds from the
|
||||
// cached JSON. An in-flight load's own post-await ownership check
|
||||
// removes whatever that stale generation adds later.
|
||||
parts.rendering.releaseDataSources(state._viewer);
|
||||
},
|
||||
|
||||
update(viewer) {
|
||||
@@ -98,7 +92,9 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
|
||||
|
||||
destroy(viewer) {
|
||||
if (state._abort) state._abort.abort();
|
||||
parts.rendering.destroyScene(viewer || state._viewer);
|
||||
parts.rendering.releaseDataSources(viewer || state._viewer);
|
||||
state._cachedCableJson = null;
|
||||
state._cachedLandingJson = null;
|
||||
// hide() clears every published host entry and goes invisible while
|
||||
// keeping the publisher reusable — the legacy layer supported re-init
|
||||
// after destroy, and hidden publishers already drop late publishes.
|
||||
@@ -132,6 +128,16 @@ export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
|
||||
state._error = null;
|
||||
state._lastUpdate = null;
|
||||
state._loadingLabel = '';
|
||||
state._cableDataSource = null;
|
||||
state._landingDataSource = null;
|
||||
state._referenceDataSource = null;
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
state._markerBlendDone = false;
|
||||
state._markerBlendInvariantWarned = false;
|
||||
parts.rendering.resetPublishSignature();
|
||||
state._referenceSweepGate.reset();
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ export function selectCableReferenceLabelWinners(
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.distanceM - b.distanceM ||
|
||||
String(a.id || '').localeCompare(String(b.id || '')),
|
||||
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')),
|
||||
)
|
||||
.slice(0, cap);
|
||||
}
|
||||
@@ -248,15 +248,16 @@ export function createCableReferenceSweepGate({
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute one stem tip on the sweep cadence. The tip is moved in place, so
|
||||
* the label entry keeps pointing at it, and only when the camera-scaled tip
|
||||
* moved beyond the settle epsilon; the tip point follows with one buffer
|
||||
* write, and the caller redraws the stems when any tip moved.
|
||||
* @param {object} record Reference record with point/base/tip/nextTip.
|
||||
* Recompute one staticized stem on the sweep cadence. Replaces the former
|
||||
* per-frame `CallbackProperty` pair: constant properties are redefined only
|
||||
* when the camera-scaled tip moved beyond the settle epsilon, and the two
|
||||
* preallocated position buffers alternate so each real change raises exactly
|
||||
* one geometry notification (the localGeojson double-buffer pattern).
|
||||
* @param {object} record Reference record with entity/base/tip/nextTip/buffers.
|
||||
* @param {Cesium.Cartesian3} cameraPositionWC
|
||||
* @param {number} canvasHeight CSS-pixel canvas height.
|
||||
* @param {number} fov Camera frustum field of view (radians).
|
||||
* @returns {boolean} True when the tip moved.
|
||||
* @returns {boolean} True when the stem geometry was redefined.
|
||||
*/
|
||||
|
||||
export function updateCableReferenceStem(
|
||||
@@ -288,7 +289,13 @@ export function updateCableReferenceStem(
|
||||
return false;
|
||||
}
|
||||
Cesium.Cartesian3.clone(record.nextTip, record.tip);
|
||||
record.point.position = record.tip;
|
||||
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
|
||||
const stemPositions =
|
||||
record.stemPositionBuffers[record.stemPositionBufferIndex];
|
||||
stemPositions[0] = record.base;
|
||||
stemPositions[1] = record.tip;
|
||||
record.entity.position.setValue(record.tip);
|
||||
record.entity.polyline.positions.setValue(stemPositions);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export const BASE_CABLE_COLOR = '#39d5ff';
|
||||
|
||||
export const BASE_LANDING_COLOR = '#8fffd2';
|
||||
@@ -64,22 +66,6 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
|
||||
depthTested: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Landing markers clamped to the ground per rendered frame. A marker is put
|
||||
* on the surface the first time it comes into view, and that reads the
|
||||
* terrain height under it. Doing it for all 1,917 in the frame after the
|
||||
* layer was switched on was a single task (measured 1.2 s on a loaded
|
||||
* globe); a few dozen per frame keeps each frame short and the markers still
|
||||
* fill in within a second. A marker is clamped once: after that Cesium moves
|
||||
* it as terrain loads, and hiding or showing it reads nothing.
|
||||
*/
|
||||
|
||||
export const CABLE_LANDING_REVEALS_PER_FRAME = 48;
|
||||
|
||||
/** Pixel size of the marker drawn for point geometry (GeoJSON's small pin). */
|
||||
|
||||
export const CABLE_MARKER_SIZE = 6;
|
||||
|
||||
/**
|
||||
* `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion
|
||||
* Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that
|
||||
@@ -95,6 +81,19 @@ export const CABLE_GLOBE_STACK_IDS = Object.freeze(
|
||||
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
|
||||
);
|
||||
|
||||
/** EntityCluster's private marker collections and their required types. */
|
||||
|
||||
export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([
|
||||
Object.freeze({
|
||||
key: '_billboardCollection',
|
||||
type: Cesium.BillboardCollection,
|
||||
}),
|
||||
Object.freeze({
|
||||
key: '_pointCollection',
|
||||
type: Cesium.PointPrimitiveCollection,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Monotonic clock for the sweep gate's motion probe. A hoisted declaration:
|
||||
* the module's `export default createTeleGeographySubmarineCableLayer()`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CABLE_LANDING_REVEALS_PER_FRAME,
|
||||
CABLE_MARKER_SIZE,
|
||||
} from './policy.js';
|
||||
probeTranslucentMarkerBlend,
|
||||
commitTranslucentMarkerBlend,
|
||||
} from './surface.js';
|
||||
import {
|
||||
clampLabel,
|
||||
createCableOverlayEntry,
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
cableReferencePriority,
|
||||
} from './overlay.js';
|
||||
|
||||
const defaultMarkerImage = (color) =>
|
||||
new Cesium.PinBuilder().fromColor(color, CABLE_MARKER_SIZE);
|
||||
|
||||
export function createRendering({
|
||||
state,
|
||||
createMarkerImage = defaultMarkerImage,
|
||||
}) {
|
||||
export function createRendering({ state }) {
|
||||
function resetPublishSignature() {
|
||||
state._lastPublishedIds.length = 0;
|
||||
state._lastPublishedPriorities.length = 0;
|
||||
@@ -25,131 +19,129 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Build everything the layer draws, in one synchronous pass with no entity
|
||||
* per feature. The layer used to load two GeoJsonDataSources and a
|
||||
* CustomDataSource: 6,459 entities whose creation held about 1 GB of
|
||||
* short-lived heap, whose visualizers ran 100–250 ms tasks whenever shows
|
||||
* or stems changed, and whose removal on disable was a 150 ms task. These
|
||||
* are the primitives Cesium built from those entities, made directly:
|
||||
*
|
||||
* - lines: one GroundPolylinePrimitive, a colour and a show flag per line
|
||||
* (Cesium's own batch for clamped colour polylines), tessellated in a
|
||||
* worker;
|
||||
* - point markers: one BillboardCollection, each billboard hidden and
|
||||
* unclamped until revealLandingMarkers puts it on the ground;
|
||||
* - references: one PointPrimitiveCollection for the tips, whose positions
|
||||
* and shows are plain buffer writes, and one Primitive of polylines for
|
||||
* the stems (see rebuildStems).
|
||||
*
|
||||
* `lines`, `markers` and `references` come from ingestion; each line and
|
||||
* marker carries the pick record Cesium returns as the pick id.
|
||||
* Remove the three data sources from the viewer and forget the built
|
||||
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
|
||||
* DataSourceDisplay still walks every visualizer of a hidden source on
|
||||
* every frame, and the thousands of hidden polylines/billboards keep
|
||||
* costing CPU and GC pressure long after the user toggled the layer off —
|
||||
* that is the "everything gets sluggish after I turn layers off" report.
|
||||
* The fetched JSON stays cached, so the next enable rebuilds without a
|
||||
* network round trip or a re-parse.
|
||||
*/
|
||||
|
||||
function buildScene(viewer, { lines, markers, references }) {
|
||||
const scene = viewer.scene;
|
||||
state._lineRecords = [];
|
||||
state._markerRecords = [];
|
||||
function releaseDataSources(viewer) {
|
||||
const sources = [
|
||||
state._cableDataSource,
|
||||
state._landingDataSource,
|
||||
state._referenceDataSource,
|
||||
];
|
||||
state._cableDataSource = null;
|
||||
state._landingDataSource = null;
|
||||
state._referenceDataSource = null;
|
||||
state._referenceRecords = [];
|
||||
state._pendingLandingReveals.length = 0;
|
||||
|
||||
if (lines.length) {
|
||||
// The entity display gave every line this attribute too. It is a float
|
||||
// attribute, and with one present Cesium keeps the per-line width as a
|
||||
// float; without it the 2.5 px width is stored as a byte and draws 2 px.
|
||||
const always =
|
||||
Cesium.DistanceDisplayConditionGeometryInstanceAttribute.fromDistanceDisplayCondition(
|
||||
new Cesium.DistanceDisplayCondition(),
|
||||
);
|
||||
const instances = lines.map(
|
||||
(line) =>
|
||||
new Cesium.GeometryInstance({
|
||||
id: line.info,
|
||||
geometry: new Cesium.GroundPolylineGeometry({
|
||||
positions: line.positions,
|
||||
width: line.width,
|
||||
// GeoJSON lines are rhumb lines (GeoJsonDataSource's choice).
|
||||
arcType: Cesium.ArcType.RHUMB,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
line.color,
|
||||
),
|
||||
show: new Cesium.ShowGeometryInstanceAttribute(true),
|
||||
distanceDisplayCondition: always,
|
||||
},
|
||||
}),
|
||||
);
|
||||
state._cablePrimitive = scene.groundPrimitives.add(
|
||||
new Cesium.GroundPolylinePrimitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PolylineColorAppearance(),
|
||||
classificationType: state._classificationType,
|
||||
asynchronous: true,
|
||||
show: state._enabled,
|
||||
}),
|
||||
);
|
||||
for (const line of lines)
|
||||
state._lineRecords.push({
|
||||
info: line.info,
|
||||
base: line.base,
|
||||
visible: true,
|
||||
shown: true,
|
||||
attributes: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (markers.length) {
|
||||
// Every marker is translucent, so one blend pass, not Cesium's two.
|
||||
const billboards = scene.primitives.add(
|
||||
new Cesium.BillboardCollection({
|
||||
scene,
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
show: state._enabled,
|
||||
}),
|
||||
);
|
||||
state._markerBillboards = billboards;
|
||||
const images = new Map();
|
||||
for (const marker of markers) {
|
||||
const key = marker.color.toCssHexString();
|
||||
let image = images.get(key);
|
||||
if (!image) {
|
||||
image = {
|
||||
id: `gev-cable-marker-${key}`,
|
||||
source: createMarkerImage(marker.color),
|
||||
};
|
||||
images.set(key, image);
|
||||
}
|
||||
const billboard = billboards.add({
|
||||
id: marker.info,
|
||||
position: marker.position,
|
||||
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
|
||||
show: false,
|
||||
});
|
||||
billboard.setImage(image.id, image.source);
|
||||
state._markerRecords.push({
|
||||
info: marker.info,
|
||||
marker: billboard,
|
||||
base: marker.base,
|
||||
// A position with its own height is drawn there, as before.
|
||||
clamped: marker.hasHeight,
|
||||
visible: false,
|
||||
});
|
||||
state._surfaceRecords = [];
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
// The blend pass targets the collections being dropped; the rebuilt
|
||||
// sources need their own pass.
|
||||
state._markerBlendDone = false;
|
||||
state._loaded = false;
|
||||
state._referenceSweepGate.reset();
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
try {
|
||||
viewer?.dataSources?.remove(source, true);
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (references.length) {
|
||||
state._referencePoints = scene.primitives.add(
|
||||
new Cesium.PointPrimitiveCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
show: state._enabled,
|
||||
}),
|
||||
);
|
||||
for (const reference of references) addReferenceStem(reference);
|
||||
function updateVisibility() {
|
||||
if (state._cableDataSource) state._cableDataSource.show = state._enabled;
|
||||
if (state._landingDataSource)
|
||||
state._landingDataSource.show = state._enabled;
|
||||
if (state._referenceDataSource)
|
||||
state._referenceDataSource.show = state._enabled;
|
||||
}
|
||||
|
||||
function styleCableEntity(entity, feature) {
|
||||
if (!entity?.polyline) return;
|
||||
const color = feature?.properties?.color
|
||||
? Cesium.Color.fromCssColorString(String(feature.properties.color))
|
||||
: state.cableColor;
|
||||
entity.polyline.material = color.withAlpha(0.92);
|
||||
entity.polyline.width = 2.5;
|
||||
entity.polyline.clampToGround = true;
|
||||
entity.polyline.classificationType = state._classificationType;
|
||||
entity.show = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the single translucent blend pass to the landing-billboard and
|
||||
* reference-point collections. Landing entities carry only billboards and
|
||||
* reference entities only points, so completion is one applied collection
|
||||
* per source; a failed shape probe disables the optimization permanently
|
||||
* for this session and says so loudly in dev builds.
|
||||
* Both sources are probed BEFORE either is committed, so the helper's
|
||||
* "defaults untouched on shape drift" fallback holds across the pair too —
|
||||
* a reference-source failure can never leave the landing source already
|
||||
* forced to TRANSLUCENT.
|
||||
*/
|
||||
|
||||
function applyMarkerBlendOnce() {
|
||||
const landing = probeTranslucentMarkerBlend(state._landingDataSource);
|
||||
const reference = probeTranslucentMarkerBlend(state._referenceDataSource);
|
||||
if (landing.invariantFailed || reference.invariantFailed) {
|
||||
state._markerBlendDone = true;
|
||||
if (!state._markerBlendInvariantWarned && import.meta.env?.DEV === true) {
|
||||
state._markerBlendInvariantWarned = true;
|
||||
console.error(
|
||||
'[Data:telegeography-submarine-cables] EntityCluster marker-collection ' +
|
||||
'shape changed — single-pass translucent blend skipped; markers fall ' +
|
||||
"back to Cesium's default two-pass blend.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
state._lineShowDirty = false;
|
||||
const landingBlend = commitTranslucentMarkerBlend(landing);
|
||||
const referenceBlend = commitTranslucentMarkerBlend(reference);
|
||||
if (landingBlend.applied > 0 && referenceBlend.applied > 0)
|
||||
state._markerBlendDone = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-classify every cable ground line for the active surface. One batched
|
||||
* ground-primitive rebuild per stack switch — never per frame.
|
||||
* @param {Cesium.ClassificationType} next
|
||||
*/
|
||||
|
||||
function applyCableClassification(next) {
|
||||
if (next === undefined || next === state._classificationType) return;
|
||||
state._classificationType = next;
|
||||
if (!state._cableDataSource) return;
|
||||
const entities = state._cableDataSource.entities.values;
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const polyline = entities[i].polyline;
|
||||
if (polyline) polyline.classificationType = next;
|
||||
}
|
||||
state._viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
function styleLandingEntity(entity, feature) {
|
||||
if (!entity?.point) return;
|
||||
entity.point.color = state.landingColor.withAlpha(0.92);
|
||||
entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7;
|
||||
entity.point.outlineColor = state.cableOutline;
|
||||
entity.point.outlineWidth = 1;
|
||||
entity.point.disableDepthTestDistance = 0;
|
||||
entity.show = true;
|
||||
}
|
||||
|
||||
function addReferenceStem({ reference, label, kind, color, feature }) {
|
||||
if (!state._referenceDataSource || !reference) return;
|
||||
|
||||
const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0);
|
||||
const tip = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
@@ -162,31 +154,42 @@ export function createRendering({
|
||||
label,
|
||||
featureId: feature?.id || feature?.properties?.id || null,
|
||||
};
|
||||
state._pickRecords.add(info);
|
||||
const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94);
|
||||
// The tip is moved in place on the sweep cadence (overlay.js), never by a
|
||||
// per-frame callback.
|
||||
const point = state._referencePoints.add({
|
||||
id: info,
|
||||
position: tip,
|
||||
pixelSize: kind === 'cable' ? 7 : 8,
|
||||
color: pointColor,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: 0,
|
||||
show: false,
|
||||
});
|
||||
const record = {
|
||||
const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68);
|
||||
// Constant properties on the sweep cadence, never per-frame callbacks:
|
||||
// the sweep redefines them through the alternating buffers below.
|
||||
const stemPositionBuffers = [
|
||||
[base, tip],
|
||||
[base, tip],
|
||||
];
|
||||
|
||||
const entity = state._referenceDataSource.entities.add({
|
||||
id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
|
||||
info,
|
||||
point,
|
||||
position: tip,
|
||||
polyline: {
|
||||
positions: stemPositionBuffers[0],
|
||||
width: kind === 'cable' ? 2 : 2.4,
|
||||
material: new Cesium.ColorMaterialProperty(stemColor),
|
||||
},
|
||||
point: {
|
||||
pixelSize: kind === 'cable' ? 7 : 8,
|
||||
color: pointColor,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: 0,
|
||||
},
|
||||
});
|
||||
|
||||
entity.__gevTeleGeography = info;
|
||||
state._pickByEntity.set(entity, info);
|
||||
const record = {
|
||||
id: entity.id,
|
||||
entity,
|
||||
base,
|
||||
tip,
|
||||
nextTip: Cesium.Cartesian3.clone(tip),
|
||||
stemColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color.withAlpha(kind === 'cable' ? 0.58 : 0.68),
|
||||
),
|
||||
stemWidth: kind === 'cable' ? 2 : 2.4,
|
||||
stemPositionBuffers,
|
||||
stemPositionBufferIndex: 0,
|
||||
reference,
|
||||
kind,
|
||||
label: clampLabel(label),
|
||||
@@ -198,212 +201,6 @@ export function createRendering({
|
||||
state._referenceRecords.push(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the stems of the references now in view, base to tip. Stems are one
|
||||
* static polyline primitive, as the entity display batched them: nothing
|
||||
* runs for it per frame, and a stem's length cannot change without new
|
||||
* geometry. So a sweep that moved a tip or changed which references are
|
||||
* in view builds a replacement in a worker, and settleStems swaps it in
|
||||
* once it is ready; the previous stems stay until then. A sweep that
|
||||
* changed nothing builds nothing.
|
||||
*/
|
||||
|
||||
function rebuildStems() {
|
||||
const primitives = state._viewer?.scene?.primitives;
|
||||
if (!primitives) return;
|
||||
if (state._stemPrimitivePending) {
|
||||
// Superseded before it was ever shown.
|
||||
primitives.remove(state._stemPrimitivePending);
|
||||
state._stemPrimitivePending = null;
|
||||
}
|
||||
const instances = [];
|
||||
for (const record of state._referenceRecords) {
|
||||
if (!record.visible) continue;
|
||||
instances.push(
|
||||
new Cesium.GeometryInstance({
|
||||
id: record.info,
|
||||
geometry: new Cesium.PolylineGeometry({
|
||||
positions: [record.base, record.tip],
|
||||
width: record.stemWidth,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: { color: record.stemColor },
|
||||
}),
|
||||
);
|
||||
}
|
||||
state._stemsDirty = false;
|
||||
if (!instances.length) {
|
||||
if (state._stemPrimitive) primitives.remove(state._stemPrimitive);
|
||||
state._stemPrimitive = null;
|
||||
return;
|
||||
}
|
||||
state._stemPrimitivePending = primitives.add(
|
||||
new Cesium.Primitive({
|
||||
geometryInstances: instances,
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
asynchronous: true,
|
||||
// Hidden while it builds; settleStems shows it.
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
state._viewer.scene.requestRender?.();
|
||||
}
|
||||
|
||||
/** Swap in rebuilt stems once their geometry is ready; called per frame. */
|
||||
|
||||
function settleStems() {
|
||||
const pending = state._stemPrimitivePending;
|
||||
if (!pending) return;
|
||||
const scene = state._viewer?.scene;
|
||||
if (!pending.ready) {
|
||||
// Keep frames coming until the geometry is in.
|
||||
scene?.requestRender?.();
|
||||
return;
|
||||
}
|
||||
pending.show = state._enabled;
|
||||
if (state._stemPrimitive) scene?.primitives?.remove(state._stemPrimitive);
|
||||
state._stemPrimitive = pending;
|
||||
state._stemPrimitivePending = null;
|
||||
scene?.requestRender?.();
|
||||
}
|
||||
|
||||
/** Remove and destroy everything buildScene made. Only destroy() does this;
|
||||
* a disable hides the primitives and keeps them for the next enable. */
|
||||
|
||||
function destroyScene(viewer) {
|
||||
const scene = viewer?.scene;
|
||||
const owned = [
|
||||
[scene?.groundPrimitives, state._cablePrimitive],
|
||||
[scene?.primitives, state._markerBillboards],
|
||||
[scene?.primitives, state._stemPrimitive],
|
||||
[scene?.primitives, state._stemPrimitivePending],
|
||||
[scene?.primitives, state._referencePoints],
|
||||
];
|
||||
state._cablePrimitive = null;
|
||||
state._markerBillboards = null;
|
||||
state._stemPrimitive = null;
|
||||
state._stemPrimitivePending = null;
|
||||
state._stemsDirty = false;
|
||||
state._referencePoints = null;
|
||||
state._referenceRecords = [];
|
||||
state._lineRecords = [];
|
||||
state._markerRecords = [];
|
||||
state._lineShowDirty = false;
|
||||
state._pendingLandingReveals.length = 0;
|
||||
state._pickRecords = new WeakSet();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
state._loaded = false;
|
||||
state._referenceSweepGate.reset();
|
||||
for (const [collection, primitive] of owned) {
|
||||
if (!primitive) continue;
|
||||
try {
|
||||
// A collection destroys what it removes; one that is already gone
|
||||
// has destroyed it too.
|
||||
if (!collection?.remove?.(primitive) && !primitive.isDestroyed())
|
||||
primitive.destroy();
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the stems, shown and building, so the next sweep draws them afresh.
|
||||
* A disable does this: stems kept from the view the layer was switched off
|
||||
* in would show at their old lengths until the rebuild for the new view
|
||||
* was ready.
|
||||
*/
|
||||
|
||||
function dropStems() {
|
||||
const primitives = state._viewer?.scene?.primitives;
|
||||
for (const primitive of [state._stemPrimitive, state._stemPrimitivePending])
|
||||
if (primitive) primitives?.remove(primitive);
|
||||
state._stemPrimitive = null;
|
||||
state._stemPrimitivePending = null;
|
||||
state._stemsDirty = true;
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
for (const primitive of [
|
||||
state._cablePrimitive,
|
||||
state._markerBillboards,
|
||||
state._stemPrimitive,
|
||||
state._referencePoints,
|
||||
])
|
||||
if (primitive) primitive.show = state._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-classify the cable ground lines for the active surface. The primitive
|
||||
* reads its classification as it queues commands, so this is one
|
||||
* assignment per stack switch and no rebuild.
|
||||
* @param {Cesium.ClassificationType} next
|
||||
*/
|
||||
|
||||
function applyCableClassification(next) {
|
||||
if (next === undefined || next === state._classificationType) return;
|
||||
state._classificationType = next;
|
||||
if (!state._cablePrimitive) return;
|
||||
state._cablePrimitive.classificationType = next;
|
||||
state._viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the line show states the last sweep worked out. The primitive
|
||||
* builds its geometry in a worker and has no per-line attributes until it
|
||||
* is ready, so the states wait in the records until then.
|
||||
*/
|
||||
|
||||
function syncLineShow() {
|
||||
if (!state._lineShowDirty) return;
|
||||
const primitive = state._cablePrimitive;
|
||||
if (!primitive?.ready) {
|
||||
// Keep frames coming until the geometry is in.
|
||||
if (primitive) state._viewer?.scene?.requestRender?.();
|
||||
return;
|
||||
}
|
||||
for (const record of state._lineRecords) {
|
||||
if (record.shown === record.visible) continue;
|
||||
record.attributes ??= primitive.getGeometryInstanceAttributes(
|
||||
record.info,
|
||||
);
|
||||
if (!record.attributes) continue;
|
||||
record.attributes.show = Cesium.ShowGeometryInstanceAttribute.toValue(
|
||||
record.visible,
|
||||
record.attributes.show,
|
||||
);
|
||||
record.shown = record.visible;
|
||||
}
|
||||
state._lineShowDirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the next few markers the last sweep found visible on the ground and
|
||||
* show them, and ask for another frame while any are left. Called from the
|
||||
* sweep and then once per rendered frame, so a parked camera costs nothing
|
||||
* once the list is empty. Clamping is what reads the terrain height, once
|
||||
* per marker; Cesium then keeps the marker on the surface as terrain loads,
|
||||
* within its own per-frame time budget.
|
||||
*/
|
||||
|
||||
function revealLandingMarkers() {
|
||||
const pending = state._pendingLandingReveals;
|
||||
if (!pending.length) return;
|
||||
let budget = CABLE_LANDING_REVEALS_PER_FRAME;
|
||||
while (pending.length && budget > 0) {
|
||||
const record = pending.pop();
|
||||
if (!record.visible || record.marker.show) continue;
|
||||
if (!record.clamped) {
|
||||
record.marker.heightReference = Cesium.HeightReference.CLAMP_TO_GROUND;
|
||||
record.clamped = true;
|
||||
budget--;
|
||||
}
|
||||
record.marker.show = true;
|
||||
}
|
||||
if (pending.length) state._viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
function updateReferenceVisibility() {
|
||||
if (!state._enabled || !state._viewer?.camera) return;
|
||||
const cameraPos = state._viewer.camera.positionWC;
|
||||
@@ -419,35 +216,20 @@ export function createRendering({
|
||||
const visible = occluder.isPointVisible(record.base);
|
||||
record.visible = visible;
|
||||
record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base);
|
||||
if (record.point.show !== visible) {
|
||||
record.point.show = visible;
|
||||
state._stemsDirty = true;
|
||||
if (record.entity.show !== visible) {
|
||||
record.entity.show = visible;
|
||||
}
|
||||
// Hidden stems keep their last tip; this same sweep refreshes them in
|
||||
// the pass where they turn visible again.
|
||||
if (
|
||||
visible &&
|
||||
updateCableReferenceStem(record, cameraPos, canvasHeight, fov)
|
||||
)
|
||||
state._stemsDirty = true;
|
||||
// Hidden stems keep their last geometry; this same sweep refreshes them
|
||||
// in the pass where they turn visible again.
|
||||
if (visible)
|
||||
updateCableReferenceStem(record, cameraPos, canvasHeight, fov);
|
||||
}
|
||||
if (state._stemsDirty) rebuildStems();
|
||||
for (const record of state._lineRecords) {
|
||||
record.visible = occluder.isPointVisible(record.base);
|
||||
if (record.visible !== record.shown) state._lineShowDirty = true;
|
||||
}
|
||||
syncLineShow();
|
||||
state._pendingLandingReveals.length = 0;
|
||||
for (const record of state._markerRecords) {
|
||||
for (const record of state._surfaceRecords) {
|
||||
const visible = occluder.isPointVisible(record.base);
|
||||
record.visible = visible;
|
||||
if (record.marker.show === visible) continue;
|
||||
// A marker not yet on the ground waits its turn; one that already is
|
||||
// shows at once, and hiding is immediate.
|
||||
if (visible && !record.clamped) state._pendingLandingReveals.push(record);
|
||||
else record.marker.show = visible;
|
||||
if (record.entity.show !== visible) {
|
||||
record.entity.show = visible;
|
||||
}
|
||||
}
|
||||
revealLandingMarkers();
|
||||
const winners = selectCableReferenceLabelWinners(state._referenceRecords);
|
||||
let changed = winners.length !== state._lastPublishedCount;
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
@@ -465,6 +247,9 @@ export function createRendering({
|
||||
}
|
||||
state._publishScratch.length = winners.length;
|
||||
state._referenceLabelCount = winners.length;
|
||||
// The marker collections are created lazily by the visualizers, so the
|
||||
// single-pass blend is applied on the sweep cadence until both landed.
|
||||
if (!state._markerBlendDone) applyMarkerBlendOnce();
|
||||
if (!changed) return;
|
||||
state._overlayPublisher.publish(state._publishScratch);
|
||||
state._lastPublishedCount = winners.length;
|
||||
@@ -477,14 +262,13 @@ export function createRendering({
|
||||
}
|
||||
return {
|
||||
resetPublishSignature,
|
||||
buildScene,
|
||||
destroyScene,
|
||||
releaseDataSources,
|
||||
updateVisibility,
|
||||
styleCableEntity,
|
||||
applyMarkerBlendOnce,
|
||||
applyCableClassification,
|
||||
syncLineShow,
|
||||
settleStems,
|
||||
dropStems,
|
||||
revealLandingMarkers,
|
||||
styleLandingEntity,
|
||||
addReferenceStem,
|
||||
updateReferenceVisibility,
|
||||
};
|
||||
}
|
||||
@@ -30,50 +30,26 @@ export function createState({ sweepClock, overlayHost }) {
|
||||
|
||||
state._loadGeneration = 0;
|
||||
|
||||
state._cableDataSource = null;
|
||||
|
||||
state._landingDataSource = null;
|
||||
|
||||
state._referenceDataSource = null;
|
||||
|
||||
/**
|
||||
* What the layer draws, built once per load and kept across disable/enable
|
||||
* (a disable only hides them, which costs nothing per frame):
|
||||
* one ground polyline primitive for every line, one billboard collection
|
||||
* for every point marker, a point collection for the reference tips and
|
||||
* one polyline primitive for the reference stems.
|
||||
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
|
||||
* the three data sources (see releaseDataSources) instead of hiding them,
|
||||
* so a re-enable has to rebuild the entities; the network/parse half of
|
||||
* that rebuild is the expensive part users would notice, so it is cached.
|
||||
*/
|
||||
|
||||
state._cablePrimitive = null;
|
||||
state._cachedCableJson = null;
|
||||
|
||||
state._markerBillboards = null;
|
||||
|
||||
state._referencePoints = null;
|
||||
|
||||
/** The stems now drawn, and their replacement while it builds. */
|
||||
|
||||
state._stemPrimitive = null;
|
||||
|
||||
state._stemPrimitivePending = null;
|
||||
|
||||
state._stemsDirty = false;
|
||||
state._cachedLandingJson = null;
|
||||
|
||||
state._referenceRecords = [];
|
||||
|
||||
/** One record per drawn line: its pick record, horizon base and show state. */
|
||||
|
||||
state._lineRecords = [];
|
||||
|
||||
/** Line show states still to be written once the primitive is ready. */
|
||||
|
||||
state._lineShowDirty = false;
|
||||
|
||||
/** One record per point marker: its billboard and horizon base. */
|
||||
|
||||
state._markerRecords = [];
|
||||
|
||||
/** Markers the last sweep found visible but not on the ground yet; drained
|
||||
* a few per frame (CABLE_LANDING_REVEALS_PER_FRAME). */
|
||||
|
||||
state._pendingLandingReveals = [];
|
||||
|
||||
/** The pick records handed to Cesium as ids, so a pick is known to be ours. */
|
||||
|
||||
state._pickRecords = new WeakSet();
|
||||
state._surfaceRecords = [];
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
@@ -87,6 +63,14 @@ export function createState({ sweepClock, overlayHost }) {
|
||||
|
||||
state._classificationType = Cesium.ClassificationType.BOTH;
|
||||
|
||||
/** True once both marker collections blend translucent (or the shape probe failed). */
|
||||
|
||||
state._markerBlendDone = false;
|
||||
|
||||
state._markerBlendInvariantWarned = false;
|
||||
|
||||
state._pickByEntity = new WeakMap();
|
||||
|
||||
state._referenceLabelCount = 0;
|
||||
|
||||
state._referenceSweepGate = createCableReferenceSweepGate({
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CABLE_GLOBE_STACK_IDS } from './policy.js';
|
||||
import {
|
||||
CABLE_GLOBE_STACK_IDS,
|
||||
MARKER_COLLECTION_EXPECTATIONS,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Ground-line classification for one map stack. The photoreal stack renders
|
||||
@@ -7,7 +10,7 @@ import { CABLE_GLOBE_STACK_IDS } from './policy.js';
|
||||
* need the 3D-tile classification pass there; every other known stack (the
|
||||
* bing stacks and osm) renders imagery on the shown globe, so only the
|
||||
* terrain pass applies.
|
||||
* Classifying against just the active surface halves the
|
||||
* Classifying against just the active surface halves the batched
|
||||
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
|
||||
* for an unknown stack — it renders on every surface, exactly the shipped
|
||||
* pre-optimization behavior.
|
||||
@@ -37,3 +40,87 @@ export function cableClassificationTypeForScene(scene) {
|
||||
? Cesium.ClassificationType.CESIUM_3D_TILE
|
||||
: Cesium.ClassificationType.TERRAIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one data source's marker collections WITHOUT mutating anything.
|
||||
* Splitting validation from mutation is what makes the fallback honest: a
|
||||
* shape failure on the SECOND collection (or the second data source) must
|
||||
* not leave the first one already forced to TRANSLUCENT.
|
||||
* @param {Cesium.DataSource|null|undefined} dataSource
|
||||
* @returns {{ready:object[],pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function probeTranslucentMarkerBlend(dataSource) {
|
||||
const cluster = dataSource?.clustering;
|
||||
if (!cluster) return { ready: [], pending: 0, invariantFailed: true };
|
||||
const ready = [];
|
||||
let pending = 0;
|
||||
let invariantFailed = false;
|
||||
for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) {
|
||||
if (!(key in cluster)) {
|
||||
invariantFailed = true;
|
||||
continue;
|
||||
}
|
||||
const collection = cluster[key];
|
||||
if (collection === undefined || collection === null) {
|
||||
pending++;
|
||||
continue;
|
||||
}
|
||||
if (!(collection instanceof type) || !('blendOption' in collection)) {
|
||||
invariantFailed = true;
|
||||
continue;
|
||||
}
|
||||
ready.push(collection);
|
||||
}
|
||||
// Any failure discards every collected target: nothing is mutated at all.
|
||||
if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true };
|
||||
return { ready, pending, invariantFailed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a clean probe. Only ever called once every probe in the batch has
|
||||
* passed, so this cannot land a partial application.
|
||||
* @param {{ready:object[],pending:number}} probe
|
||||
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function commitTranslucentMarkerBlend(probe) {
|
||||
for (const collection of probe.ready) {
|
||||
if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) {
|
||||
collection.blendOption = Cesium.BlendOption.TRANSLUCENT;
|
||||
}
|
||||
}
|
||||
return {
|
||||
applied: probe.ready.length,
|
||||
pending: probe.pending,
|
||||
invariantFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force one translucent draw pass for a data source's marker collections.
|
||||
* The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per
|
||||
* collection, and every cable marker is translucent (alpha < 1), so the
|
||||
* opaque pass is pure overhead across 1,917 landing billboards + 2,629
|
||||
* reference points. Cesium has no public accessor for the entity-backed
|
||||
* collections, so this reaches through EntityCluster's private fields under
|
||||
* a SHAPE INVARIANT the caller must honor loudly: the constructor
|
||||
* pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a
|
||||
* visualizer lazily creates them), so a missing PROPERTY means Cesium's
|
||||
* shape changed — report `invariantFailed` and never touch anything —
|
||||
* while a present-but-undefined value only means "not created yet"
|
||||
* (`pending`). Wrong instance types also fail the invariant. The cables
|
||||
* sources never enable clustering, so created collections persist.
|
||||
* VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first
|
||||
* assignment, so a failure anywhere leaves BOTH collections on Cesium's
|
||||
* default blend — the fallback this helper promises.
|
||||
* @param {Cesium.DataSource|null|undefined} dataSource
|
||||
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function applyTranslucentMarkerBlend(dataSource) {
|
||||
const probe = probeTranslucentMarkerBlend(dataSource);
|
||||
if (probe.invariantFailed)
|
||||
return { applied: 0, pending: 0, invariantFailed: true };
|
||||
return commitTranslucentMarkerBlend(probe);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { roadIdentity } from './retention.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
floorCellReader,
|
||||
prepareRoadSurfaces,
|
||||
roadHeightStations,
|
||||
trafficSurfaceReady,
|
||||
@@ -301,6 +302,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
}
|
||||
if (depthHeight !== undefined) layerState._viewHeightEstimate = depthHeight;
|
||||
const floorAt = floorCellReader(services.ground);
|
||||
for (const road of roads) {
|
||||
road.key ||= roadIdentity(road);
|
||||
const prepared = !refining && preparedRoads.get(road.key);
|
||||
@@ -319,7 +321,7 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
for (let i = 0; i < road.coords.length; i++) {
|
||||
const [lon, lat] = road.coords[i];
|
||||
const floor = services.ground?.cachedGroundFloor?.(lat, lon);
|
||||
const floor = floorAt(lat, lon);
|
||||
const height =
|
||||
Number.isFinite(floor) && Math.abs(floor) <= 9000
|
||||
? Math.max(floor, layerState._viewHeightEstimate)
|
||||
|
||||
@@ -39,10 +39,10 @@ export function createTrafficLayer({ services, source }) {
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
state._parseRoads = TRAFFIC_TIMING_ENABLED
|
||||
? (data, trace) =>
|
||||
? (data, trace, parsedBySource) =>
|
||||
trace
|
||||
? parts.timing.parseRoadsTimed(data, trace)
|
||||
: parts.model.parseRoads(data)
|
||||
? parts.timing.parseRoadsTimed(data, trace, parsedBySource)
|
||||
: parts.model.parseRoads(data, trace, parsedBySource)
|
||||
: parts.model.parseRoads;
|
||||
|
||||
state._loadRoadsForBounds = TRAFFIC_TIMING_ENABLED
|
||||
|
||||
@@ -348,11 +348,16 @@ export function createIngestion({
|
||||
layerState._retainedPreview = false;
|
||||
layerState._detailRoadsReady = false;
|
||||
let paintRevision = 0;
|
||||
// The snapshot that ends a pass replaces the streamed roads with the
|
||||
// composed set, which is mostly the tile records already parsed and
|
||||
// drawn. Remembering those per pass means only the records new to that
|
||||
// snapshot (the clipped coarse roads) are parsed, not the whole set again.
|
||||
let parsedBySource = new WeakMap();
|
||||
const onTile = (data, passSignal, preview) => {
|
||||
if (generation !== layerState._loadGeneration || passSignal.aborted)
|
||||
return;
|
||||
const start = performance.now();
|
||||
const parsed = layerState._parseRoads(data, trace);
|
||||
const parsed = layerState._parseRoads(data, trace, parsedBySource);
|
||||
phaseTiming('parse', start, { roads: parsed.length, incremental: true });
|
||||
if (data.replace) streamed = parsed;
|
||||
else streamed.push(...parsed);
|
||||
@@ -531,6 +536,7 @@ export function createIngestion({
|
||||
if (altitude > FAST_FETCH_ALTITUDE) return;
|
||||
|
||||
streamed = [];
|
||||
parsedBySource = new WeakMap();
|
||||
majorPass = false;
|
||||
// Detailed pass: fetch the full road graph (tertiary, residential, etc.)
|
||||
console.log(`[Data:Traffic] Full fetch local roads [${cacheKey}]`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { floorCellReader, roadSurfaceChunks } from './surface.js';
|
||||
import { t, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
flowDensityMult,
|
||||
@@ -13,22 +13,35 @@ import {
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/** Build scene waypoints from source records; thinning and terrain remain rendering policy. */
|
||||
function parseRoads(roadData) {
|
||||
/**
|
||||
* Build scene waypoints from source records; thinning and terrain remain rendering policy.
|
||||
*
|
||||
* `parsedBySource` (source record → its parsed roads) is filled as records
|
||||
* are parsed, and a record already in it is answered from it.
|
||||
*/
|
||||
function parseRoads(roadData, _trace, parsedBySource) {
|
||||
if (!roadData || !roadData.roads) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const roads = [];
|
||||
const floorAt = floorCellReader(services.ground);
|
||||
for (const road of roadData.roads) {
|
||||
if (!road.coordinates || road.coordinates.length < 2) continue;
|
||||
// A replacing snapshot repeats records this load already parsed.
|
||||
const known = parsedBySource?.get(road);
|
||||
if (known) {
|
||||
for (const parsed of known) roads.push(parsed);
|
||||
continue;
|
||||
}
|
||||
const first = roads.length;
|
||||
|
||||
for (const coords of roadSurfaceChunks(road.coordinates)) {
|
||||
const type = road.type;
|
||||
const oneway = road.oneway;
|
||||
// Pre-compute Cartesian3 waypoints (lon, lat, height) for fast lerp animation
|
||||
const waypoints = coords.map(([lng, lat]) => {
|
||||
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
|
||||
const floor = floorAt(lat, lng);
|
||||
const h =
|
||||
(Number.isFinite(floor) && Math.abs(floor) <= 9000
|
||||
? floor
|
||||
@@ -60,6 +73,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
flow: road.flow || null,
|
||||
});
|
||||
}
|
||||
parsedBySource?.set(road, roads.slice(first));
|
||||
}
|
||||
|
||||
return roads;
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createTrafficSource, outsideDetailTiles } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { floorCellReader, roadSurfaceChunks } from './surface.js';
|
||||
import { DOT_HEIGHT_OFFSET } from './policy.js';
|
||||
import { clipTileLine } from '../../sources/openFreeMap.js';
|
||||
import { tileToBBox } from '../../data/tomtomTiles.js';
|
||||
|
||||
// The clip as it was before the extent shortcut: every road against the four
|
||||
// regions around every tile. The shortcut must return exactly this.
|
||||
function clipEveryRoadAgainstEveryTile(roads, tiles) {
|
||||
return roads.flatMap((road) => {
|
||||
let lines = [road.coordinates];
|
||||
for (const tile of tiles) {
|
||||
const box = tileToBBox(tile.z, tile.x, tile.y);
|
||||
const regions = [
|
||||
{ west: -180, east: box.west, south: -85, north: 85 },
|
||||
{ west: box.east, east: 180, south: -85, north: 85 },
|
||||
{ west: box.west, east: box.east, south: -85, north: box.south },
|
||||
{ west: box.west, east: box.east, south: box.north, north: 85 },
|
||||
];
|
||||
lines = lines.flatMap((line) =>
|
||||
regions.flatMap((region) => clipTileLine(line, region)),
|
||||
);
|
||||
}
|
||||
return lines.map((coordinates) => ({ ...road, coordinates }));
|
||||
});
|
||||
}
|
||||
|
||||
function seeded(seed) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
function tileBlock(x, y, size = 4) {
|
||||
const tiles = [];
|
||||
for (let dx = 0; dx < size; dx++)
|
||||
for (let dy = 0; dy < size; dy++)
|
||||
tiles.push({ z: 14, x: x + dx, y: y + dy });
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** Roads in and around a tile block: wandering, straight, tiny, and on tile edges. */
|
||||
function roadsAround(tiles, random, count = 400) {
|
||||
const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
|
||||
const west = Math.min(...boxes.map((box) => box.west)),
|
||||
east = Math.max(...boxes.map((box) => box.east)),
|
||||
south = Math.min(...boxes.map((box) => box.south)),
|
||||
north = Math.max(...boxes.map((box) => box.north));
|
||||
const width = east - west,
|
||||
height = north - south;
|
||||
const roads = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const step = i % 7 === 0 ? 0.00002 : i % 3 === 0 ? 0.01 : 0.002;
|
||||
let lon = west - width / 2 + random() * width * 2,
|
||||
lat = south - height / 2 + random() * height * 2;
|
||||
const coordinates = [[lon, lat]];
|
||||
const points = 1 + Math.floor(random() * 12);
|
||||
for (let j = 0; j < points; j++) {
|
||||
lon += (random() - 0.5) * step;
|
||||
lat += (random() - 0.5) * step;
|
||||
coordinates.push([lon, lat]);
|
||||
}
|
||||
roads.push({ type: 'primary', oneway: i % 2, id: i, coordinates });
|
||||
}
|
||||
const box = boxes[5];
|
||||
const midLat = (box.south + box.north) / 2,
|
||||
midLon = (box.west + box.east) / 2;
|
||||
roads.push(
|
||||
// Along a tile edge, ending on a corner, and touching an edge from outside.
|
||||
{
|
||||
id: 'edge',
|
||||
coordinates: [
|
||||
[box.west, box.south - 0.01],
|
||||
[box.west, box.north + 0.01],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'corner',
|
||||
coordinates: [
|
||||
[box.west - 0.01, box.south - 0.01],
|
||||
[box.west, box.south],
|
||||
[box.east, box.north],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'touch',
|
||||
coordinates: [
|
||||
[box.west - 0.01, midLat],
|
||||
[box.west, midLat],
|
||||
],
|
||||
},
|
||||
// Across the whole block, and a repeated vertex.
|
||||
{
|
||||
id: 'across',
|
||||
coordinates: [
|
||||
[west - 0.01, midLat],
|
||||
[east + 0.01, midLat + 0.0003],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'repeat',
|
||||
coordinates: [
|
||||
[midLon, midLat],
|
||||
[midLon, midLat],
|
||||
[midLon + 0.001, midLat],
|
||||
],
|
||||
},
|
||||
// Records the clip drops or leaves alone.
|
||||
{ id: 'single', coordinates: [[west - 0.02, midLat]] },
|
||||
{ id: 'empty', coordinates: [] },
|
||||
{
|
||||
id: 'negative-zero',
|
||||
coordinates: [
|
||||
[-0, south - 0.02],
|
||||
[0.001, south - 0.02],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'polar',
|
||||
coordinates: [
|
||||
[west - 0.02, 86],
|
||||
[west - 0.02, 86.01],
|
||||
],
|
||||
},
|
||||
);
|
||||
return roads;
|
||||
}
|
||||
|
||||
for (const [place, x, y] of [
|
||||
['Manhattan', 4823, 6158],
|
||||
['London, across longitude 0', 8190, 5446],
|
||||
['longitude 0 and latitude 0', 8190, 8190],
|
||||
['the southern and eastern hemispheres', 15070, 9826],
|
||||
])
|
||||
test(`coarse roads clipped around detail tiles match the every-tile clip: ${place}`, () => {
|
||||
const tiles = tileBlock(x, y);
|
||||
const roads = roadsAround(tiles, seeded(x * 31 + y));
|
||||
for (const loaded of [tiles, tiles.slice(0, 5), tiles.slice(3, 4), []]) {
|
||||
const expected = clipEveryRoadAgainstEveryTile(roads, loaded);
|
||||
assert.ok(loaded.length === 0 || expected.length !== roads.length);
|
||||
// deepStrictEqual separates -0 from 0 and compares every vertex exactly.
|
||||
assert.deepStrictEqual(outsideDetailTiles(roads, loaded), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('only a road that reaches a detail tile is clipped against it', () => {
|
||||
const tiles = tileBlock(4823, 6158);
|
||||
const block = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
|
||||
const west = Math.min(...block.map((box) => box.west)),
|
||||
north = Math.max(...block.map((box) => box.north));
|
||||
// 300 roads beside the block and between its tile edges, none touching one.
|
||||
const clear = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const box = block[i % block.length];
|
||||
const lon = i % 2 ? west - 0.02 - i * 1e-5 : box.west + 0.004;
|
||||
const lat = i % 2 ? box.south + 0.004 : north + 0.01 + i * 1e-5;
|
||||
clear.push({
|
||||
coordinates: [
|
||||
[lon, lat],
|
||||
[lon + 0.002, lat + 0.0005],
|
||||
[lon + 0.003, lat + 0.001],
|
||||
],
|
||||
});
|
||||
}
|
||||
const first = block[0];
|
||||
const crossing = {
|
||||
coordinates: [
|
||||
[first.west + 0.004, north + 0.01],
|
||||
[first.west + 0.004, (first.south + first.north) / 2],
|
||||
],
|
||||
};
|
||||
let clips = 0;
|
||||
const counted = (line, region) => {
|
||||
clips++;
|
||||
return clipTileLine(line, region);
|
||||
};
|
||||
const roads = outsideDetailTiles([...clear, crossing], tiles, counted);
|
||||
assert.deepStrictEqual(
|
||||
roads,
|
||||
clipEveryRoadAgainstEveryTile([...clear, crossing], tiles),
|
||||
);
|
||||
assert.equal(roads.length, 301);
|
||||
// Four regions for each tile the crossing road reaches, never the 19,264
|
||||
// clips of every road against every tile.
|
||||
assert.ok(clips >= 4, `expected the crossing road to be clipped: ${clips}`);
|
||||
assert.ok(clips <= 4 * tiles.length, `clipped ${clips} times`);
|
||||
});
|
||||
|
||||
/** A DEM-regime ground service: one floor per toFixed(3) cell, counting reads. */
|
||||
function cellGround({ mesh = false, warm = () => true } = {}) {
|
||||
const ground = {
|
||||
reads: 0,
|
||||
meshFloorPreferred: () => mesh,
|
||||
cachedGroundFloor(lat, lon) {
|
||||
ground.reads++;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
// Number() as the shared cache does it: "-0.000" and "0.000" are one cell.
|
||||
const cell = `${Number(lat.toFixed(3))},${Number(lon.toFixed(3))}`;
|
||||
if (!warm(cell)) return null;
|
||||
let hash = 0;
|
||||
for (const character of cell)
|
||||
hash = (Math.imul(hash, 31) + character.charCodeAt(0)) | 0;
|
||||
// The mesh regime answers per point, not per cell.
|
||||
return (hash % 4000) / 10 + (mesh ? lat - Math.floor(lat * 1000) / 1000 : 0);
|
||||
},
|
||||
};
|
||||
return ground;
|
||||
}
|
||||
|
||||
test('floor reads are shared per cell and equal the per-coordinate reads', () => {
|
||||
const random = seeded(7);
|
||||
const points = [];
|
||||
for (let i = 0; i < 4000; i++)
|
||||
points.push([40.75 + random() * 0.01, -73.99 + random() * 0.01]);
|
||||
// Cell edges, where toFixed(3) and Math.round can disagree, in each sign.
|
||||
for (const lat of [40.7505, -40.7505, 0.0005, -0.0005, 1.0005, 8.5745])
|
||||
for (const lon of [-73.9905, 73.9905, 0.0005, -0.0005, 1.0005, 2.3515])
|
||||
points.push([lat, lon], [lat + 1e-9, lon - 1e-9], [lat - 1e-9, lon]);
|
||||
points.push([Number.NaN, 0], [0, Infinity], [0, 0], [-0, -0]);
|
||||
|
||||
const direct = cellGround();
|
||||
const expected = points.map(([lat, lon]) =>
|
||||
direct.cachedGroundFloor(lat, lon),
|
||||
);
|
||||
const ground = cellGround();
|
||||
const floorAt = floorCellReader(ground);
|
||||
assert.deepStrictEqual(
|
||||
points.map(([lat, lon]) => floorAt(lat, lon)),
|
||||
expected,
|
||||
);
|
||||
// 4000 points in a 0.01 degree square share about 121 cells.
|
||||
assert.ok(ground.reads < 600, `read the shared cache ${ground.reads} times`);
|
||||
|
||||
// A cold cell is remembered as cold for the pass, not asked again.
|
||||
const cold = cellGround({ warm: () => false });
|
||||
const coldAt = floorCellReader(cold);
|
||||
assert.equal(coldAt(40.7512, -73.9812), null);
|
||||
assert.equal(coldAt(40.7513, -73.9813), null);
|
||||
assert.equal(cold.reads, 1);
|
||||
});
|
||||
|
||||
test('mesh floors and unknown ground services are read per coordinate', () => {
|
||||
const mesh = cellGround({ mesh: true });
|
||||
const meshAt = floorCellReader(mesh);
|
||||
const a = meshAt(40.7512, -73.9812),
|
||||
b = meshAt(40.75121, -73.9812);
|
||||
assert.equal(mesh.reads, 2);
|
||||
assert.notEqual(a, b);
|
||||
|
||||
// No regime to ask: nothing says a floor is the same across a cell.
|
||||
let reads = 0;
|
||||
const bareAt = floorCellReader({ cachedGroundFloor: () => ++reads });
|
||||
bareAt(40.7512, -73.9812);
|
||||
bareAt(40.7512, -73.9812);
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(floorCellReader(undefined)(1, 2), undefined);
|
||||
assert.equal(floorCellReader({})(1, 2), undefined);
|
||||
});
|
||||
|
||||
function road(lon, lat, points = 40) {
|
||||
const coordinates = [];
|
||||
for (let i = 0; i < points; i++)
|
||||
coordinates.push([lon + i * 0.00004, lat + i * 0.00003]);
|
||||
return { type: 'residential', oneway: 1, coordinates };
|
||||
}
|
||||
|
||||
test('parsing reads each floor cell once and builds the same waypoints', () => {
|
||||
const ground = cellGround({ warm: (cell) => !cell.endsWith('3') });
|
||||
const state = { _viewHeightEstimate: 12 };
|
||||
const model = createModel({
|
||||
state,
|
||||
services: { ground },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const records = [road(-73.99, 40.75), road(-73.9899, 40.7501)];
|
||||
const parsed = model.parseRoads({ roads: records });
|
||||
const coordinates = records.reduce(
|
||||
(count, record) => count + record.coordinates.length,
|
||||
0,
|
||||
);
|
||||
assert.ok(
|
||||
ground.reads < coordinates / 3,
|
||||
`${ground.reads} floor reads for ${coordinates} coordinates`,
|
||||
);
|
||||
|
||||
const direct = cellGround({ warm: (cell) => !cell.endsWith('3') });
|
||||
const expected = records.flatMap((record) =>
|
||||
roadSurfaceChunks(record.coordinates).map((coords) =>
|
||||
coords.map(([lon, lat]) => {
|
||||
const floor = direct.cachedGroundFloor(lat, lon);
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
(floor ?? state._viewHeightEstimate) + DOT_HEIGHT_OFFSET,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.ok(direct.reads >= coordinates);
|
||||
assert.deepStrictEqual(
|
||||
parsed.map((entry) => entry.waypoints),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('a record parsed earlier in the pass is not parsed again', () => {
|
||||
const ground = cellGround();
|
||||
const model = createModel({
|
||||
state: {},
|
||||
services: { ground },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const first = { ...road(-73.99, 40.75), oneway: 0 },
|
||||
second = road(-73.98, 40.76),
|
||||
short = { type: 'primary', coordinates: [[-73.97, 40.77]] };
|
||||
const parsedBySource = new WeakMap();
|
||||
const tile = model.parseRoads({ roads: [first] }, null, parsedBySource);
|
||||
assert.equal(tile.length, 2, 'a two-way record parses to both directions');
|
||||
const reads = ground.reads;
|
||||
const replaced = model.parseRoads(
|
||||
{ roads: [second, short, first], replace: true },
|
||||
null,
|
||||
parsedBySource,
|
||||
);
|
||||
assert.equal(replaced.length, 3);
|
||||
// The same parsed roads, in the snapshot's order: dots stay attached.
|
||||
assert.equal(replaced[1], tile[0]);
|
||||
assert.equal(replaced[2], tile[1]);
|
||||
const fresh = cellGround();
|
||||
createModel({
|
||||
state: {},
|
||||
services: { ground: fresh },
|
||||
parts: {},
|
||||
source: {},
|
||||
}).parseRoads({ roads: [second] });
|
||||
assert.equal(ground.reads - reads, fresh.reads, 'only the new record read');
|
||||
|
||||
// Without the map every call parses, as before.
|
||||
const again = model.parseRoads({ roads: [first] });
|
||||
assert.notEqual(again[0], tile[0]);
|
||||
assert.deepStrictEqual(again, tile);
|
||||
});
|
||||
|
||||
test('the snapshot that ends a pass parses only the records it adds', async () => {
|
||||
const detail = { z: 14, x: 3743, y: 6745 };
|
||||
const core = tileToBBox(detail.z, detail.x, detail.y);
|
||||
const local = {
|
||||
type: 'residential',
|
||||
oneway: 1,
|
||||
coordinates: [
|
||||
[core.west + 0.002, core.south + 0.002],
|
||||
[core.west + 0.004, core.south + 0.004],
|
||||
],
|
||||
};
|
||||
const distant = {
|
||||
type: 'primary',
|
||||
oneway: 1,
|
||||
coordinates: [
|
||||
[core.east + 0.01, core.south],
|
||||
[core.east + 0.02, core.south],
|
||||
],
|
||||
};
|
||||
const bounds = {
|
||||
...core,
|
||||
east: core.east + 0.03,
|
||||
coverage: {
|
||||
detail: [detail],
|
||||
coarse: [{ z: 12, x: 935, y: 1686 }],
|
||||
key: 'test',
|
||||
},
|
||||
};
|
||||
const source = createTrafficSource({
|
||||
mapTiles: {
|
||||
async fetchBounds(box, { zoom, onTile }) {
|
||||
const tile = { roads: zoom === 14 ? [local] : [distant] };
|
||||
onTile?.(tile);
|
||||
return {
|
||||
tiles: [tile],
|
||||
loadedTiles: zoom === 14 ? [detail] : bounds.coverage.coarse,
|
||||
partial: false,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const parsedRecords = [];
|
||||
const painted = [];
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_loadGeneration: 0,
|
||||
_tileCache: new Map(),
|
||||
_roadMode: null,
|
||||
_liveMode: false,
|
||||
// The model's contract: fill the map, answer known records from it.
|
||||
_parseRoads: (data, trace, parsedBySource) =>
|
||||
data.roads.flatMap((record) => {
|
||||
const known = parsedBySource?.get(record);
|
||||
if (known) return known;
|
||||
parsedRecords.push(record);
|
||||
const parsed = [{ record }];
|
||||
parsedBySource?.set(record, parsed);
|
||||
return parsed;
|
||||
}),
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
source,
|
||||
services: {},
|
||||
parts: {
|
||||
viewport: {
|
||||
clampBounds: (b) => b,
|
||||
getBoundsCenter: () => ({ lat: 30.26, lon: -97.74 }),
|
||||
},
|
||||
flow: {
|
||||
warmFlow: () => Promise.resolve([]),
|
||||
applyFlowThenRender: async (roads, box, generation, altitude, label) => {
|
||||
if (label === 'Loaded full') painted.push(roads);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await ingestion.loadRoadsForBounds(bounds, 350);
|
||||
assert.equal(
|
||||
parsedRecords.filter((record) => record === local).length,
|
||||
1,
|
||||
'the detail tile record is parsed when its tile streams, not again',
|
||||
);
|
||||
// The full graph is still the detail record plus the coarse road outside it.
|
||||
assert.equal(painted.at(-1).length, 2);
|
||||
assert.equal(painted.at(-1)[0].record, local);
|
||||
assert.deepStrictEqual(
|
||||
painted.at(-1)[1].record.coordinates,
|
||||
distant.coordinates,
|
||||
);
|
||||
});
|
||||
+100
-15
@@ -33,23 +33,108 @@ export function trafficDetailBounds(box) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** Keep coarse roads only outside the detailed tile cores. */
|
||||
export function outsideDetailTiles(roads, tiles) {
|
||||
/** Longitude/latitude extent of one line; NaN coordinates leave it unusable. */
|
||||
function lineExtent(coordinates) {
|
||||
let west = Infinity,
|
||||
east = -Infinity,
|
||||
south = Infinity,
|
||||
north = -Infinity;
|
||||
for (const [lon, lat] of coordinates) {
|
||||
if (!(lon >= west)) west = lon;
|
||||
if (!(lon <= east)) east = lon;
|
||||
if (!(lat >= south)) south = lat;
|
||||
if (!(lat <= north)) north = lat;
|
||||
}
|
||||
return { coordinates, west, east, south, north, settled: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* What `clipTileLine` returns for a line lying wholly inside the clip region:
|
||||
* the same line with each vertex recomputed from the one before it. Returns
|
||||
* null for a line under the 12 m floor `clipTileLine` drops.
|
||||
*/
|
||||
function unclippedLine(coordinates) {
|
||||
if (coordinates.length < 2) return null;
|
||||
const out = [];
|
||||
let metres = 0;
|
||||
for (let i = 1; i < coordinates.length; i++) {
|
||||
const a = coordinates[i - 1],
|
||||
b = coordinates[i];
|
||||
const dx = b[0] - a[0],
|
||||
dy = b[1] - a[1];
|
||||
if (i === 1) out.push([a[0] + 0 * dx, a[1] + 0 * dy]);
|
||||
out.push([a[0] + dx, a[1] + dy]);
|
||||
metres +=
|
||||
Math.hypot(
|
||||
(out[i][0] - out[i - 1][0]) * Math.cos((out[i][1] * Math.PI) / 180),
|
||||
out[i][1] - out[i - 1][1],
|
||||
) * 111320;
|
||||
}
|
||||
return metres >= 12 ? out : null;
|
||||
}
|
||||
|
||||
const sameLine = (a, b) =>
|
||||
a.every(
|
||||
(point, i) => Object.is(point[0], b[i][0]) && Object.is(point[1], b[i][1]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep coarse roads only outside the detailed tile cores.
|
||||
*
|
||||
* Each tile splits a line into the four regions around its core. Nearly every
|
||||
* coarse road lies wholly inside one of those regions for nearly every tile,
|
||||
* where the clip hands the line back unchanged, so that case is answered from
|
||||
* the line's extent and only a line that reaches a tile's core or edge is
|
||||
* clipped. Clipping every road against every tile froze the page for hundreds
|
||||
* of milliseconds per road load at street level.
|
||||
*
|
||||
* `clip` is injectable for tests.
|
||||
*/
|
||||
export function outsideDetailTiles(roads, tiles, clip = clipTileLine) {
|
||||
const boxes = tiles.map((tile) => tileToBBox(tile.z, tile.x, tile.y));
|
||||
return roads.flatMap((road) => {
|
||||
let lines = [road.coordinates];
|
||||
for (const tile of tiles) {
|
||||
const box = tileToBBox(tile.z, tile.x, tile.y);
|
||||
const regions = [
|
||||
{ west: -180, east: box.west, south: -85, north: 85 },
|
||||
{ west: box.east, east: 180, south: -85, north: 85 },
|
||||
{ west: box.west, east: box.east, south: -85, north: box.south },
|
||||
{ west: box.west, east: box.east, south: box.north, north: 85 },
|
||||
];
|
||||
lines = lines.flatMap((line) =>
|
||||
regions.flatMap((region) => clipTileLine(line, region)),
|
||||
);
|
||||
let lines = [lineExtent(road.coordinates)];
|
||||
for (const box of boxes) {
|
||||
const next = [];
|
||||
for (const line of lines) {
|
||||
// Strictly inside one region, and inside the bounds every region
|
||||
// shares: the other three regions return nothing.
|
||||
const clear =
|
||||
line.west > -180 &&
|
||||
line.east < 180 &&
|
||||
line.south > -85 &&
|
||||
line.north < 85 &&
|
||||
(line.east < box.west ||
|
||||
line.west > box.east ||
|
||||
(line.west > box.west &&
|
||||
line.east < box.east &&
|
||||
(line.north < box.south || line.south > box.north)));
|
||||
if (!clear) {
|
||||
const regions = [
|
||||
{ west: -180, east: box.west, south: -85, north: 85 },
|
||||
{ west: box.east, east: 180, south: -85, north: 85 },
|
||||
{ west: box.west, east: box.east, south: -85, north: box.south },
|
||||
{ west: box.west, east: box.east, south: box.north, north: 85 },
|
||||
];
|
||||
for (const region of regions)
|
||||
for (const coordinates of clip(line.coordinates, region))
|
||||
next.push(lineExtent(coordinates));
|
||||
} else if (line.settled) {
|
||||
next.push(line);
|
||||
} else {
|
||||
// The first unclipped pass can still move a vertex by a rounding
|
||||
// step (a road crossing longitude or latitude 0); once a pass
|
||||
// changes nothing, no later one will.
|
||||
const coordinates = unclippedLine(line.coordinates);
|
||||
if (!coordinates) continue;
|
||||
const kept = lineExtent(coordinates);
|
||||
kept.settled = sameLine(coordinates, line.coordinates);
|
||||
next.push(kept);
|
||||
}
|
||||
}
|
||||
lines = next;
|
||||
}
|
||||
return lines.map((coordinates) => ({ ...road, coordinates }));
|
||||
return lines.map(({ coordinates }) => ({ ...road, coordinates }));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,45 @@ export function trafficSurfaceReady(scene) {
|
||||
return currentRenderedFrame(scene)?.ready === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground-floor reads for one synchronous pass over road coordinates, asking
|
||||
* the shared cache once per floor cell instead of once per coordinate.
|
||||
*
|
||||
* A road tile puts thousands of coordinates into a few hundred ~111 m cells,
|
||||
* and each shared read formats and looks up two string keys, which added up
|
||||
* to seconds per street-level load. Mesh floors answer per point (the nearest
|
||||
* reading in the cell), so they are read through unchanged; only the DEM
|
||||
* regime is one value per cell. Build a reader per pass: cells warm up
|
||||
* between passes.
|
||||
*/
|
||||
export function floorCellReader(ground) {
|
||||
const read = ground?.cachedGroundFloor;
|
||||
if (!read) return () => undefined;
|
||||
if (ground.meshFloorPreferred?.() !== false)
|
||||
return (lat, lon) => read(lat, lon);
|
||||
const cells = new Map();
|
||||
return (lat, lon) => {
|
||||
const y = lat * 1000,
|
||||
x = lon * 1000;
|
||||
const row = Math.round(y),
|
||||
column = Math.round(x);
|
||||
// The shared cache rounds with toFixed(3). At a cell edge that and
|
||||
// Math.round can pick different cells, so an edge point asks directly.
|
||||
if (!(
|
||||
Math.abs(Math.abs(y - row) - 0.5) > 1e-6 &&
|
||||
Math.abs(Math.abs(x - column) - 0.5) > 1e-6
|
||||
))
|
||||
return read(lat, lon);
|
||||
const key = row * 1e6 + column;
|
||||
let floor = cells.get(key);
|
||||
if (floor === undefined) {
|
||||
floor = read(lat, lon) ?? null;
|
||||
cells.set(key, floor);
|
||||
}
|
||||
return floor;
|
||||
};
|
||||
}
|
||||
|
||||
/** Preserve bends and insert height samples at most 150 m apart, splitting long roads. */
|
||||
export function roadSurfaceChunks(coordinates) {
|
||||
const chunks = [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadSurfaceChunks } from './surface.js';
|
||||
import { floorCellReader, roadSurfaceChunks } from './surface.js';
|
||||
import { TRAFFIC_TIMING_ENABLED, DOT_HEIGHT_OFFSET } from './policy.js';
|
||||
|
||||
export function createTiming({ state: layerState, services, parts, source }) {
|
||||
@@ -190,7 +190,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
* waypoint-materialization time independently.
|
||||
*/
|
||||
|
||||
function parseRoadsTimed(roadData, trace) {
|
||||
function parseRoadsTimed(roadData, trace, parsedBySource) {
|
||||
/* TRACE_ONLY_BEGIN */
|
||||
const _trafficTimingState = trafficTimingPass(
|
||||
trace,
|
||||
@@ -231,11 +231,19 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
const roads = [];
|
||||
const floorAt = floorCellReader(services.ground);
|
||||
/* TRACE_ONLY_BEGIN */
|
||||
let _trafficTimingWaypointMaterializationMs = 0;
|
||||
/* TRACE_ONLY_END */
|
||||
for (const road of roadData.roads) {
|
||||
if (!road.coordinates || road.coordinates.length < 2) continue;
|
||||
// A replacing snapshot repeats records this load already parsed.
|
||||
const known = parsedBySource?.get(road);
|
||||
if (known) {
|
||||
for (const parsed of known) roads.push(parsed);
|
||||
continue;
|
||||
}
|
||||
const first = roads.length;
|
||||
|
||||
for (const coords of roadSurfaceChunks(road.coordinates)) {
|
||||
const type = road.type;
|
||||
@@ -245,7 +253,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
const _trafficTimingMaterializeStart = performance.now();
|
||||
/* TRACE_ONLY_END */
|
||||
const waypoints = coords.map(([lng, lat]) => {
|
||||
const floor = services.ground?.cachedGroundFloor?.(lat, lng);
|
||||
const floor = floorAt(lat, lng);
|
||||
const h =
|
||||
(Number.isFinite(floor) && Math.abs(floor) <= 9000
|
||||
? floor
|
||||
@@ -279,6 +287,7 @@ export function createTiming({ state: layerState, services, parts, source }) {
|
||||
flow: road.flow || null,
|
||||
});
|
||||
}
|
||||
parsedBySource?.set(road, roads.slice(first));
|
||||
}
|
||||
/* TRACE_ONLY_BEGIN */
|
||||
const _trafficTimingMetrics = {
|
||||
|
||||
Reference in new issue
Block a user