Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 6521fd2316 docs(changelog): add the #1705 fragment
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:05:53 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 346a08a42e perf(traffic): stop clipping and re-parsing the whole road set in one task
After the detail tiles arrived, every coarse road was clipped against every
detail tile for four regions, the composed set was parsed again although
each tile had been parsed as it streamed, and every coordinate asked the
ground floor service for its cell. A road wholly inside one region is now
answered from its extent, records already parsed in a pass are reused, and
the floor is read once per cell within a parse (when floors are per cell).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 16:03:19 +01:00
24 changed files with 1918 additions and 1383 deletions

No files matched your search

+4
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
-55
View File
@@ -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;
}
+1 -2
View File
@@ -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,
+174 -93
View File
@@ -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();
+30 -7
View File
@@ -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,
+23 -17
View File
@@ -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();
},
+15 -8
View File
@@ -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;
}
+15 -16
View File
@@ -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()`
+166 -382
View File
@@ -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,
};
}
+21 -37
View File
@@ -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({
+89 -2
View File
@@ -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);
}
+3 -1
View File
@@ -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)
+3 -3
View File
@@ -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
+7 -1
View File
@@ -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}]`);
+18 -4
View File
@@ -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;
+444
View File
@@ -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
View File
@@ -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 }));
});
}
+39
View File
@@ -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 = [];
+12 -3
View File
@@ -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 = {