mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-18 17:49:53 +00:00
2737 lines
107 KiB
TypeScript
2737 lines
107 KiB
TypeScript
import { followViewport } from './cinematicChrome';
|
|
import { prefersReducedMotion as displayReducedMotion } from './displayPreferences';
|
|
import { rememberNode, replaceInspector } from './selection';
|
|
import * as maplibregl from 'maplibre-gl';
|
|
import workerURL from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
|
|
import {
|
|
type ExpressionSpecification,
|
|
type GeoJSONSource,
|
|
type GeoJSONSourceDiff,
|
|
type MapMouseEvent
|
|
} from 'maplibre-gl';
|
|
import type {
|
|
Feature,
|
|
FeatureCollection,
|
|
LineString,
|
|
Point
|
|
} from 'geojson';
|
|
import { colorWithAlpha as alphaColor } from './trafficVisuals';
|
|
import { basemapFontStack, basemapProvider, cartoVectorRequestURL, cartoVectorStyle } from './basemap';
|
|
import { longitudeDelta } from './worldGeometry';
|
|
import { DEFAULT_UI_PREFERENCES, viewClass, type UiPreferences } from './preferences';
|
|
import { FOLLOW_DWELL_MS, followEndpoints } from './liveFollow';
|
|
import {
|
|
buildNodeInspectorModel,
|
|
createNodeInspectorContent,
|
|
relativeTime,
|
|
observationTotal,
|
|
searchNodes,
|
|
type NodeSearchResult,
|
|
} from './nodeInspector';
|
|
import { HistoricalRouteLayer, ROUTE_WEBGL_LAYER_ID } from './routeLayer';
|
|
import { applyOverlayPalette } from './overlayPalette';
|
|
import { updateBuildings } from './buildings';
|
|
import { displayPreferences } from './displayPreferences';
|
|
import { isRecentNeighborRoute, recentNeighborRoutes } from './routeFocus';
|
|
import type { MapChanges } from './state';
|
|
import {
|
|
decayedRouteTraffic,
|
|
PACKET_KIND_COLORS,
|
|
PACKET_KINDS,
|
|
payloadColor,
|
|
ROUTE_BRIGHT_AGE_MS,
|
|
ROUTE_MAX_AGE_MS,
|
|
type PacketKind
|
|
} from './trafficVisuals';
|
|
import type { EndpointV2, NodeV2, PacketView, RouteV2, StateV2 } from './types';
|
|
|
|
// Worker responses cache their own CSP. Refresh that cache when providers change,
|
|
// even if the bundled worker bytes (and their content hash) stay identical.
|
|
maplibregl.setWorkerUrl(`${workerURL}?policy=openfreemap-v1`);
|
|
|
|
export const DEFAULT_CENTER: [number, number] = [0, 20];
|
|
export const DEFAULT_ZOOM = 1.4;
|
|
export const DETAIL_ZOOM = 8.4;
|
|
export const LIVE_FOLLOW_SAFE_RATIO = 0.6;
|
|
export const LIVE_FOLLOW_MIN_INTERVAL_MS = FOLLOW_DWELL_MS;
|
|
export const ACTIVE_NODE_WINDOW_MS = 24 * 60 * 60_000;
|
|
|
|
export function mapPixelRatio(devicePixelRatio: number, lowPower: boolean): number {
|
|
const ratio = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
|
return Math.max(1, Math.min(lowPower ? 1.5 : 2, ratio));
|
|
}
|
|
|
|
export type RouteWindow = 'auto' | '15m' | '1h' | '6h' | '24h';
|
|
export type RouteRepresentation = 'national-trunks' | 'regional-trunks' | 'individual-routes';
|
|
|
|
const EMPTY_POINTS: FeatureCollection<Point> = { type: 'FeatureCollection', features: [] };
|
|
const EMPTY_LINES: FeatureCollection<LineString> = { type: 'FeatureCollection', features: [] };
|
|
const ACTIVITY_HEAT_SOURCE_ID = 'activity-heat-source';
|
|
const NODE_SOURCE_ID = 'nodes';
|
|
const NODE_CLUSTER_SOURCE_ID = 'node-clusters';
|
|
const TERRAIN_SOURCE_ID = 'mapterhorn-dem';
|
|
const HILLSHADE_SOURCE_ID = 'mapterhorn-hillshade-dem';
|
|
const TERRAIN_TILEJSON_URL = 'https://tiles.mapterhorn.com/tilejson.json';
|
|
const ROUTE_DETAIL_SOURCE_ID = 'route-details';
|
|
const ROUTE_FOCUS_SOURCE_ID = 'route-focus';
|
|
const FOLLOW_SOURCE_ID = 'live-follow-activity';
|
|
export const HEATMAP_LAYER_IDS = PACKET_KINDS.map((kind) => `activity-heat-${kind.toLowerCase()}`);
|
|
export const HEATMAP_LAYER_ID = HEATMAP_LAYER_IDS[0]!;
|
|
export const HILLSHADE_LAYER_ID = 'terrain-hillshade';
|
|
export const ROUTE_HIT_LAYER_ID = 'route-hit';
|
|
export const NODE_HIT_LAYER_ID = 'node-hit';
|
|
export const ROUTE_FOCUS_LAYER_IDS = ['route-focus-glow', 'route-focus-core'] as const;
|
|
export const ROUTE_FILTER_LAYER_IDS = [ROUTE_HIT_LAYER_ID] as const;
|
|
export const SELECTED_NODE_LAYER_ID = 'selected-node';
|
|
export const SELECTED_NODE_OUTER_LAYER_ID = 'selected-node-outer';
|
|
export const NEIGHBOR_NODE_LAYER_ID = 'neighbor-nodes';
|
|
export const ROUTE_HOVER_LAYER_IDS = ['route-hover-glow', 'route-hover-core'] as const;
|
|
const ROUTE_NATIONAL_LAYER_IDS = ['route-national-glow', 'route-national-core'] as const;
|
|
const ROUTE_REGIONAL_LAYER_IDS = ['route-regional-glow', 'route-regional-core'] as const;
|
|
export const ROUTE_VISUAL_LAYER_IDS = [
|
|
...ROUTE_NATIONAL_LAYER_IDS,
|
|
...ROUTE_REGIONAL_LAYER_IDS
|
|
] as const;
|
|
export const CLUSTER_HIGHLIGHT_LAYER_ID = 'cluster-highlight';
|
|
export const CLUSTER_LAYER_IDS = ['clusters-glow', 'clusters', CLUSTER_HIGHLIGHT_LAYER_ID, 'cluster-count'] as const;
|
|
const NODE_GLOW_LAYER_ID = 'nodes-glow';
|
|
const NODE_LAYER_ID = 'nodes';
|
|
const NODE_CORE_LAYER_ID = 'node-core';
|
|
const NODE_LABEL_LAYER_ID = 'node-labels';
|
|
const UNCLUSTERED_NODE_LAYER_IDS = [
|
|
NODE_GLOW_LAYER_ID,
|
|
NEIGHBOR_NODE_LAYER_ID,
|
|
SELECTED_NODE_OUTER_LAYER_ID,
|
|
SELECTED_NODE_LAYER_ID,
|
|
NODE_LAYER_ID,
|
|
NODE_CORE_LAYER_ID,
|
|
NODE_HIT_LAYER_ID,
|
|
] as const;
|
|
const NODE_BASE_FILTER = ['!', ['has', 'point_count']] as ActiveLayerFilter;
|
|
|
|
export const HEAT_RENDER_BUDGET = 600;
|
|
const ROUTE_REPRESENTATION_EXACT = 'exact';
|
|
const ROUTE_REPRESENTATION_NATIONAL = 'national';
|
|
const ROUTE_REPRESENTATION_REGIONAL = 'regional';
|
|
const ROUTE_SOURCE_BUILD_BATCH = 256;
|
|
export const ROUTE_LIVE_UPDATE_INTERVAL_MS = 8_000;
|
|
|
|
export function routeHydrationDelay(lastStartedAt: number, now: number): number {
|
|
return Math.max(0, ROUTE_LIVE_UPDATE_INTERVAL_MS - Math.max(0, now - lastStartedAt));
|
|
}
|
|
const ROUTE_WINDOW_BUCKETS = [
|
|
{ key: '15m', suffix: '15m', ms: 15 * 60_000 },
|
|
{ key: '1h', suffix: '1h', ms: 60 * 60_000 },
|
|
{ key: '6h', suffix: '6h', ms: 6 * 60 * 60_000 },
|
|
{ key: '24h', suffix: '24h', ms: ROUTE_MAX_AGE_MS }
|
|
] as const;
|
|
const ROUTE_TRUNK_LEVELS = [
|
|
{ representation: ROUTE_REPRESENTATION_NATIONAL, zoom: 3.6, gridPixels: 52 },
|
|
{ representation: ROUTE_REPRESENTATION_REGIONAL, zoom: 5.4, gridPixels: 80 }
|
|
] as const;
|
|
|
|
export interface LiveMapFocus {
|
|
label: string;
|
|
neighborCount: number;
|
|
}
|
|
|
|
export interface LiveMapOptions {
|
|
appearance?: UiPreferences;
|
|
onFocusChange?: (focus: LiveMapFocus | null) => void;
|
|
onRouteRepresentationChange?: (representation: RouteRepresentation) => void;
|
|
onRouteWindowChange?: (label: string) => void;
|
|
}
|
|
|
|
export interface ViewportPoint {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface ViewportSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface TooltipSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
// Native private members keep renderer internals minifiable without renaming public/browser APIs.
|
|
export class LiveMap {
|
|
readonly map: maplibregl.Map;
|
|
#lastState?: Readonly<StateV2>;
|
|
#nodesByID = new Map<string, NodeV2>();
|
|
#routesByID = new Map<string, RouteV2>();
|
|
#routeIDsByNode = new Map<string, Set<string>>();
|
|
#nodeFeatureIDs = new Set<string>();
|
|
#clusterFeatureIDs = new Set<string>();
|
|
#heatFeatureIDs = new Set<string>();
|
|
#heatScores = new Map<string, number>();
|
|
#heatKindScores = new Map<string, Map<PacketKind, number>>();
|
|
#routeHeat = new Map<string, { endpointIDs: string[]; score: number; kind: PacketKind }>();
|
|
#heatEpoch = 0;
|
|
#routeDataDirty = true;
|
|
#routeHydrating = false;
|
|
#routeHydrationEpoch = 0;
|
|
#routeHydrationTimer?: number;
|
|
#lastRouteHydrationAt = 0;
|
|
#routeSourceRevision = 0;
|
|
#routeClock = 0;
|
|
#routeDetailFeatures = new Map<string, Feature<LineString>>();
|
|
#dirtyRouteIDs = new Set<string>();
|
|
#rebuildAllRoutes = true;
|
|
#heatDataDirty = true;
|
|
#routeWindow: RouteWindow = 'auto';
|
|
private routesVisible = true;
|
|
#heatmapVisible = true;
|
|
#clustersVisible = true;
|
|
#hillshadeVisible = false;
|
|
#terrain3D = false;
|
|
#terrainLayersReady = false;
|
|
#selectedNodeID: string | null = null;
|
|
#selectedNodeLabel = '';
|
|
#neighborNodeIDs: string[] = [];
|
|
#hoveredRouteID: string | null = null;
|
|
#routeInspectionPinned = false;
|
|
#historicalRouteLayer = new HistoricalRouteLayer();
|
|
#highlightedClusterID: number | null = null;
|
|
#clusterFlashTimer?: number;
|
|
#tooltipSignature = '';
|
|
#tooltipSize: TooltipSize = { width: 0, height: 0 };
|
|
#lastFocusSignature: string | undefined;
|
|
#inspectorSignature = '';
|
|
#nodeInspectorPopup?: maplibregl.Popup;
|
|
#nodeInspectorPopupAnchor?: 'left' | 'right';
|
|
#suppressPopupClose = false;
|
|
#lastFollowMoveAt = 0;
|
|
#followZoom = DEFAULT_ZOOM;
|
|
#followedPacket?: PacketView;
|
|
#appearance: UiPreferences = { ...DEFAULT_UI_PREFERENCES };
|
|
readonly #originalOverlayColors = new Map<string, unknown>();
|
|
#buildingSourceID?: string;
|
|
#directorTimer?: number;
|
|
#reducedMotion = prefersReducedMotion();
|
|
#freshnessTimer: number;
|
|
private renderEpoch = 0;
|
|
private renderingScheduled = false;
|
|
private renderingSources = new Set<string>();
|
|
#layersReady = false;
|
|
|
|
constructor(
|
|
private readonly container: HTMLElement,
|
|
private readonly tooltip: HTMLElement,
|
|
private readonly inspectorSheet: HTMLElement,
|
|
private readonly options: LiveMapOptions
|
|
) {
|
|
this.#appearance = { ...DEFAULT_UI_PREFERENCES, ...options.appearance };
|
|
const lowPower = window.matchMedia('(max-width: 620px), (pointer: coarse)').matches;
|
|
this.container.dataset.renderState = 'loading';
|
|
this.container.dataset.routesVisible = 'true';
|
|
this.container.dataset.heatmapVisible = 'true';
|
|
this.container.dataset.clustersVisible = 'true';
|
|
this.container.dataset.hillshadeVisible = 'false';
|
|
this.container.dataset.terrain3d = 'false';
|
|
this.container.dataset.terrainReady = 'false';
|
|
this.container.dataset.cameraPitch = '0';
|
|
this.container.dataset.cameraMoving = 'false';
|
|
this.container.dataset.selectedNodeId = '';
|
|
this.container.dataset.neighborRouteCount = '0';
|
|
this.container.dataset.focusedRouteCount = '0';
|
|
this.container.dataset.hoveredRouteId = '';
|
|
this.container.dataset.cameraMode = 'idle';
|
|
this.container.dataset.routeSourceRevision = '0';
|
|
this.container.dataset.exactRoutesLoaded = 'false';
|
|
this.container.dataset.exactRoutesReady = 'false';
|
|
this.map = new maplibregl.Map({
|
|
container: this.container,
|
|
style: cartoVectorStyle(undefined, this.#appearance.basemap),
|
|
center: DEFAULT_CENTER,
|
|
zoom: DEFAULT_ZOOM,
|
|
minZoom: 1,
|
|
maxZoom: 18,
|
|
attributionControl: false,
|
|
pitchWithRotate: true,
|
|
dragRotate: true,
|
|
touchPitch: true,
|
|
maxPitch: 65,
|
|
cooperativeGestures: false,
|
|
reduceMotion: this.#reducedMotion,
|
|
pixelRatio: mapPixelRatio(window.devicePixelRatio, lowPower),
|
|
fadeDuration: this.#reducedMotion ? 0 : 120,
|
|
renderWorldCopies: false,
|
|
transformRequest: (url) => ({ url: cartoVectorRequestURL(url) })
|
|
});
|
|
this.#setTerrainGestures(false);
|
|
this.container.dataset.routeRenderer = 'maplibre-webgl';
|
|
this.#updateRouteRepresentation();
|
|
this.map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
|
const credits = this.container.querySelector<HTMLElement>('.maplibregl-ctrl-attrib')!;
|
|
const creditSize = new ResizeObserver(() => document.documentElement.style.setProperty('--map-credits-height', `${credits.offsetHeight}px`));
|
|
creditSize.observe(credits);
|
|
this.map.once('remove', () => creditSize.disconnect());
|
|
this.map.on('load', () => this.#installLayers());
|
|
this.map.on('movestart', () => { this.container.dataset.cameraMoving = 'true'; });
|
|
this.map.on('sourcedata', (event) => {
|
|
if (this.#buildingSourceID && event.sourceId === this.#buildingSourceID) {
|
|
this.container.dataset.buildingsLoaded = String(this.map.isSourceLoaded(this.#buildingSourceID));
|
|
}
|
|
});
|
|
this.map.on('moveend', () => {
|
|
this.container.dataset.cameraMoving = 'false';
|
|
this.container.dataset.cameraZoom = String(Math.round(this.map.getZoom() * 100) / 100);
|
|
if (this.#terrain3D && viewClass() === 'desktop') {
|
|
this.#appearance.terrainPitch = this.map.getPitch();
|
|
this.#appearance.terrainBearing = this.map.getBearing();
|
|
this.container.dataset.cameraPitch = String(Math.round(this.map.getPitch() * 10) / 10);
|
|
}
|
|
});
|
|
this.map.on('zoom', this.#updateRouteRepresentation);
|
|
this.map.on('zoomend', this.#handleZoomEnd);
|
|
this.map.on('resize', this.#handleInspectorResize);
|
|
this.map.on('webglcontextrestored', this.#handleWebGLContextRestored);
|
|
document.addEventListener('keydown', this.#handleKeyDown);
|
|
this.#freshnessTimer = window.setInterval(() => this.#refreshRouteClock(), 60_000);
|
|
}
|
|
|
|
render(state: Readonly<StateV2> | undefined, changes: MapChanges | null = { reset: true }, forceFreshness = false): void {
|
|
if (!state) return;
|
|
this.#lastState = state;
|
|
if (this.#selectedNodeID && !state.nodes.some((node) => node.id === this.#selectedNodeID)) {
|
|
this.#setSelectedNode(null);
|
|
this.#hideTooltip();
|
|
}
|
|
if (!this.#layersReady) return;
|
|
if (forceFreshness || changes?.reset || this.#nodesByID.size === 0) {
|
|
this.#resetSources(state);
|
|
return;
|
|
}
|
|
|
|
const heatNodeIDs = new Set<string>();
|
|
const routeFeatureIDs = new Set(changes?.routeGeometry ?? []);
|
|
if (changes?.nodes?.length) {
|
|
for (const node of changes.nodes) {
|
|
this.#nodesByID.set(node.id, node);
|
|
heatNodeIDs.add(node.id);
|
|
}
|
|
this.#updateNodeFeatures(changes.nodes.map((node) => node.id));
|
|
}
|
|
if (changes?.routes?.length) {
|
|
const routeIDs: string[] = [];
|
|
for (const route of changes.routes) {
|
|
const previous = this.#routesByID.get(route.id);
|
|
if (previous && (previous.fromId !== route.fromId || previous.toId !== route.toId)) this.#unindexRoute(previous);
|
|
this.#routesByID.set(route.id, route);
|
|
this.#indexRoute(route);
|
|
routeIDs.push(route.id);
|
|
routeFeatureIDs.add(route.id);
|
|
}
|
|
for (const endpointID of this.#updateHeatIndex(routeIDs)) heatNodeIDs.add(endpointID);
|
|
}
|
|
if (routeFeatureIDs.size > 0) {
|
|
for (const routeID of routeFeatureIDs) this.#dirtyRouteIDs.add(routeID);
|
|
this.#routeDataDirty = true;
|
|
if (!this.#routeHydrating && this.map.getSource(ROUTE_DETAIL_SOURCE_ID)) this.#scheduleRouteHydration();
|
|
}
|
|
if (heatNodeIDs.size > 0) {
|
|
if (this.#heatmapVisible) this.#updateHeatFeatures([...heatNodeIDs]);
|
|
else this.#heatDataDirty = true;
|
|
}
|
|
if (changes?.nodes?.length || changes?.routes?.length || routeFeatureIDs.size > 0) {
|
|
this.#updateFocusData();
|
|
if (this.#hoveredRouteID && !this.#isSelectedRouteInspectable(this.#hoveredRouteID)) this.#clearRouteInspection();
|
|
if (this.#selectedNodeID) this.#applyFocusState(false);
|
|
}
|
|
// Live node and heat deltas are already painted by MapLibre. Do not restart
|
|
// the route-settle indicator for every packet during a busy burst.
|
|
}
|
|
|
|
#resetSources(state: Readonly<StateV2>): void {
|
|
const now = Date.now();
|
|
this.#nodesByID = new Map(state.nodes.map((node) => [node.id, node]));
|
|
this.#routesByID = new Map(state.routes.map((route) => [route.id, route]));
|
|
this.#rebuildRouteIndex();
|
|
const nodes = nodeCollection(state.nodes, now);
|
|
(this.map.getSource(NODE_SOURCE_ID) as GeoJSONSource).setData(nodes);
|
|
(this.map.getSource(NODE_CLUSTER_SOURCE_ID) as GeoJSONSource).setData(nodes);
|
|
this.#nodeFeatureIDs = new Set(nodes.features.map((feature) => String(feature.id)));
|
|
this.#clusterFeatureIDs = new Set(nodes.features.map((feature) => String(feature.id)));
|
|
|
|
this.#routeDataDirty = true;
|
|
this.#rebuildAllRoutes = true;
|
|
this.#dirtyRouteIDs.clear();
|
|
this.#hydrateRouteSource(now);
|
|
|
|
this.#rebuildHeatIndex(now);
|
|
this.#heatDataDirty = true;
|
|
if (this.#heatmapVisible) this.#refreshHeatSource();
|
|
|
|
this.#updateFocusData();
|
|
if (this.#selectedNodeID) this.#applyFocusState(false);
|
|
this.markRendering();
|
|
}
|
|
|
|
#updateNodeFeatures(ids: readonly string[]): boolean {
|
|
const now = Date.now();
|
|
const features = new Map<string, Feature<Point> | undefined>();
|
|
for (const id of new Set(ids)) {
|
|
const node = this.#nodesByID.get(id);
|
|
features.set(id, node && validEndpoint(node) ? nodeFeature(node, now) : undefined);
|
|
}
|
|
const nodesChanged = this.#applyFeatureDiff(NODE_SOURCE_ID, this.#nodeFeatureIDs, features);
|
|
const clustersChanged = this.#applyFeatureDiff(NODE_CLUSTER_SOURCE_ID, this.#clusterFeatureIDs, features);
|
|
return nodesChanged || clustersChanged;
|
|
}
|
|
|
|
#hydrateRouteSource(now = Date.now()): void {
|
|
const detailSource = this.map.getSource(ROUTE_DETAIL_SOURCE_ID) as GeoJSONSource | undefined;
|
|
if (!detailSource) return;
|
|
if (this.#routeHydrationTimer !== undefined) {
|
|
window.clearTimeout(this.#routeHydrationTimer);
|
|
this.#routeHydrationTimer = undefined;
|
|
}
|
|
this.#lastRouteHydrationAt = performance.now();
|
|
const hydrationEpoch = ++this.#routeHydrationEpoch;
|
|
const allRoutes = [...this.#routesByID.values()];
|
|
const routeBaseline = routeTrafficBaseline(allRoutes, now);
|
|
const rebuildAll = this.#rebuildAllRoutes || this.#routeDetailFeatures.size === 0;
|
|
const dirtyRouteIDs = new Set(this.#dirtyRouteIDs);
|
|
this.#rebuildAllRoutes = false;
|
|
this.#dirtyRouteIDs.clear();
|
|
this.#routeHydrating = true;
|
|
this.container.dataset.routeHydrating = 'true';
|
|
this.#routeDataDirty = false;
|
|
this.container.dataset.exactRoutesLoaded = 'false';
|
|
this.container.dataset.exactRoutesReady = 'false';
|
|
this.markRendering();
|
|
|
|
const active = (): boolean => hydrationEpoch === this.#routeHydrationEpoch
|
|
&& Boolean(this.map.getSource(ROUTE_DETAIL_SOURCE_ID));
|
|
const fail = (error: unknown): void => {
|
|
if (!active()) return;
|
|
this.#routeHydrating = false;
|
|
this.container.dataset.routeHydrating = 'false';
|
|
this.#routeDataDirty = true;
|
|
this.#rebuildAllRoutes = true;
|
|
this.container.dataset.renderState = 'idle';
|
|
console.warn('Route source update failed:', error instanceof Error ? error.message : error);
|
|
};
|
|
const finish = (): void => {
|
|
if (!active()) return;
|
|
this.#routeHydrating = false;
|
|
this.container.dataset.routeHydrating = 'false';
|
|
this.#trackExactRouteReadiness(hydrationEpoch);
|
|
if (this.#routeDataDirty) {
|
|
this.#emitRouteWindowChange();
|
|
this.markRendering(this.routesVisible ? [ROUTE_DETAIL_SOURCE_ID] : undefined);
|
|
this.#scheduleRouteHydration();
|
|
return;
|
|
}
|
|
this.#emitRouteWindowChange();
|
|
this.markRendering(this.routesVisible ? [ROUTE_DETAIL_SOURCE_ID] : undefined);
|
|
};
|
|
void buildRouteSourceCollections(
|
|
allRoutes,
|
|
this.#nodesByID,
|
|
now,
|
|
ROUTE_MAX_AGE_MS,
|
|
routeBaseline,
|
|
this.#routeDetailFeatures,
|
|
dirtyRouteIDs,
|
|
rebuildAll,
|
|
active
|
|
).then((collections) => {
|
|
if (!collections || !active()) return;
|
|
this.container.dataset.exactRoutesLoaded = 'true';
|
|
this.container.dataset.trunkRepresentationsLoaded = '';
|
|
this.container.dataset.routeBuildMaxSliceMs = collections.maxSliceMS.toFixed(1);
|
|
const sourceStarted = performance.now();
|
|
this.#historicalRouteLayer.setRoutes(collections.individual.features);
|
|
this.#updateTerrainRoutes();
|
|
this.#routeDetailFeatures.clear();
|
|
for (const feature of collections.individual.features) {
|
|
if (feature.id !== undefined) this.#routeDetailFeatures.set(String(feature.id), feature);
|
|
}
|
|
this.container.dataset.renderedRouteSegments = String(collections.individual.features.length);
|
|
this.container.dataset.routeSourceDispatchMs = (performance.now() - sourceStarted).toFixed(1);
|
|
this.#routeSourceRevision += 1;
|
|
this.container.dataset.routeSourceRevision = String(this.#routeSourceRevision);
|
|
this.#applyRouteTimeState(now, this.#routeClock === 0);
|
|
})
|
|
.then(finish)
|
|
.catch(fail);
|
|
}
|
|
|
|
#trackExactRouteReadiness(hydrationEpoch: number): void {
|
|
let settledFrames = 0;
|
|
const settle = (): void => {
|
|
if (hydrationEpoch !== this.#routeHydrationEpoch) return;
|
|
// Historical geometry lives in the batched renderer. The GeoJSON source
|
|
// serves selected-neighbour hit targets only and may stay unloaded while hidden.
|
|
const detailNeeded = this.routesVisible && this.#selectedNodeID !== null;
|
|
const sourceReady = this.container.dataset.exactRoutesLoaded === 'true'
|
|
&& (!detailNeeded || (Boolean(this.map.getSource(ROUTE_DETAIL_SOURCE_ID)) && this.map.isSourceLoaded(ROUTE_DETAIL_SOURCE_ID)));
|
|
if (!sourceReady || this.#routeHydrating) {
|
|
settledFrames = 0;
|
|
window.requestAnimationFrame(settle);
|
|
return;
|
|
}
|
|
settledFrames += 1;
|
|
if (settledFrames < 2) {
|
|
window.requestAnimationFrame(settle);
|
|
return;
|
|
}
|
|
this.container.dataset.exactRoutesReady = 'true';
|
|
};
|
|
window.requestAnimationFrame(settle);
|
|
}
|
|
|
|
#scheduleRouteHydration(): void {
|
|
if (this.#routeHydrationTimer !== undefined || this.#routeHydrating || !this.#routeDataDirty) return;
|
|
if (!this.map.getSource(ROUTE_DETAIL_SOURCE_ID)) return;
|
|
const delay = routeHydrationDelay(this.#lastRouteHydrationAt, performance.now());
|
|
this.#routeHydrationTimer = window.setTimeout(() => {
|
|
this.#routeHydrationTimer = undefined;
|
|
if (this.#routeHydrating) {
|
|
this.#scheduleRouteHydration();
|
|
return;
|
|
}
|
|
if (this.#routeDataDirty) this.#hydrateRouteSource();
|
|
}, delay);
|
|
}
|
|
|
|
#rebuildHeatIndex(now = Date.now()): void {
|
|
this.#heatEpoch = now;
|
|
this.#heatScores.clear();
|
|
this.#heatKindScores.clear();
|
|
this.#routeHeat.clear();
|
|
for (const route of this.#routesByID.values()) this.#addRouteHeat(route);
|
|
}
|
|
|
|
#updateHeatIndex(routeIDs: readonly string[]): string[] {
|
|
const touched = new Set<string>();
|
|
for (const id of new Set(routeIDs)) {
|
|
const previous = this.#routeHeat.get(id);
|
|
if (previous) {
|
|
for (const endpointID of previous.endpointIDs) {
|
|
this.#heatScores.set(endpointID, Math.max(0, (this.#heatScores.get(endpointID) ?? 0) - previous.score));
|
|
addKindHeat(this.#heatKindScores, endpointID, previous.kind, -previous.score);
|
|
touched.add(endpointID);
|
|
}
|
|
this.#routeHeat.delete(id);
|
|
}
|
|
const route = this.#routesByID.get(id);
|
|
if (route) {
|
|
this.#addRouteHeat(route);
|
|
for (const endpointID of new Set([route.fromId, route.toId])) touched.add(endpointID);
|
|
}
|
|
}
|
|
return [...touched];
|
|
}
|
|
|
|
#addRouteHeat(route: RouteV2): void {
|
|
if (Math.max(0, this.#heatEpoch - route.lastHeard) > ROUTE_MAX_AGE_MS) return;
|
|
const score = decayedRouteTraffic(route.traffic, route.lastHeard, this.#heatEpoch);
|
|
const endpointIDs = [...new Set([route.fromId, route.toId])];
|
|
this.#routeHeat.set(route.id, { endpointIDs, score, kind: route.lastKind });
|
|
for (const endpointID of endpointIDs) {
|
|
this.#heatScores.set(endpointID, (this.#heatScores.get(endpointID) ?? 0) + score);
|
|
addKindHeat(this.#heatKindScores, endpointID, route.lastKind, score);
|
|
}
|
|
}
|
|
|
|
#updateHeatFeatures(ids: readonly string[]): boolean {
|
|
const features = new Map<string, Feature<Point> | undefined>();
|
|
for (const id of new Set(ids)) features.set(id, heatFeature(id, this.#nodesByID, this.#heatScores, this.#heatKindScores));
|
|
const changed = this.#applyFeatureDiff(ACTIVITY_HEAT_SOURCE_ID, this.#heatFeatureIDs, features);
|
|
if (changed) this.#heatDataDirty = false;
|
|
return changed;
|
|
}
|
|
|
|
#refreshHeatSource(): void {
|
|
const collection = heatCollection(this.#nodesByID, this.#heatScores, this.#heatKindScores);
|
|
(this.map.getSource(ACTIVITY_HEAT_SOURCE_ID) as GeoJSONSource).setData(collection);
|
|
this.#heatFeatureIDs = new Set(collection.features.map((feature) => String(feature.id)));
|
|
this.#heatDataDirty = false;
|
|
}
|
|
|
|
#applyFeatureDiff<G extends Point | LineString>(
|
|
sourceID: string,
|
|
knownIDs: Set<string>,
|
|
features: ReadonlyMap<string, Feature<G> | undefined>
|
|
): boolean {
|
|
const diff: GeoJSONSourceDiff = {};
|
|
for (const [id, feature] of features) {
|
|
if (!feature) {
|
|
if (knownIDs.has(id)) {
|
|
(diff.remove ??= []).push(id);
|
|
knownIDs.delete(id);
|
|
}
|
|
continue;
|
|
}
|
|
if (!knownIDs.has(id)) {
|
|
(diff.add ??= []).push(feature);
|
|
knownIDs.add(id);
|
|
continue;
|
|
}
|
|
(diff.update ??= []).push({
|
|
id,
|
|
newGeometry: feature.geometry,
|
|
addOrUpdateProperties: Object.entries(feature.properties ?? {}).map(([key, value]) => ({ key, value }))
|
|
});
|
|
}
|
|
if (!diff.add?.length && !diff.update?.length && !diff.remove?.length) return false;
|
|
try {
|
|
(this.map.getSource(sourceID) as GeoJSONSource).updateData(diff);
|
|
} catch (error: unknown) {
|
|
console.warn(`Incremental ${sourceID} update failed:`, error instanceof Error ? error.message : error);
|
|
if (sourceID === ACTIVITY_HEAT_SOURCE_ID) this.#heatDataDirty = true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
reset(center: [number, number] = DEFAULT_CENTER, zoom = DEFAULT_ZOOM): void {
|
|
this.#lastFollowMoveAt = 0;
|
|
const orientation = this.#cameraOrientation();
|
|
if (this.#reducedMotion) {
|
|
this.map.jumpTo({ center, zoom, ...orientation });
|
|
return;
|
|
}
|
|
this.map.easeTo({ center, zoom, ...orientation, duration: 520, essential: false });
|
|
}
|
|
|
|
home(nodes: readonly NodeV2[], animate = true): void {
|
|
this.#lastFollowMoveAt = 0;
|
|
const now = Date.now();
|
|
const active = nodes.filter((node) => validEndpoint(node) && Math.max(0, now - node.lastSeen) <= ACTIVE_NODE_WINDOW_MS);
|
|
const visible = active.length > 0 ? active : nodes.filter(validEndpoint);
|
|
if (visible.length === 0) {
|
|
this.reset();
|
|
return;
|
|
}
|
|
if (visible.length === 1) {
|
|
this.reset([visible[0]!.lng, visible[0]!.lat], 6);
|
|
return;
|
|
}
|
|
const bounds = new maplibregl.LngLatBounds();
|
|
for (const node of visible) bounds.extend([node.lng, node.lat]);
|
|
const options = { padding: this.container.clientWidth <= 620 ? 48 : 72, maxZoom: 6, duration: this.#reducedMotion || !animate ? 0 : 620 };
|
|
this.map.fitBounds(bounds, options);
|
|
}
|
|
|
|
restore(center: [number, number], zoom: number, nodes: readonly NodeV2[]): boolean {
|
|
this.#lastFollowMoveAt = 0;
|
|
this.map.jumpTo({ center, zoom, ...this.#cameraOrientation() });
|
|
if (this.hasCurrentActivity(nodes)) return true;
|
|
this.home(nodes);
|
|
return false;
|
|
}
|
|
|
|
hasCurrentActivity(nodes: readonly NodeV2[], now = Date.now()): boolean {
|
|
const bounds = this.map.getBounds();
|
|
return nodes.some((node) => validEndpoint(node)
|
|
&& Math.max(0, now - node.lastSeen) <= ACTIVE_NODE_WINDOW_MS
|
|
&& bounds.contains([node.lng, node.lat]));
|
|
}
|
|
|
|
view(): { center: [number, number]; zoom: number } {
|
|
const center = this.map.getCenter();
|
|
return { center: [center.lng, center.lat], zoom: this.map.getZoom() };
|
|
}
|
|
|
|
follow(packet: PacketView): boolean {
|
|
const endpoints = packetEndpoints(packet).filter(validEndpoint);
|
|
if (endpoints.length === 0) return false;
|
|
const container = this.map.getContainer();
|
|
const viewport = { width: container.clientWidth, height: container.clientHeight };
|
|
const visible=followViewport(container);
|
|
const inside = endpoints.every(endpoint => {
|
|
const p=this.map.project([endpoint.lng,endpoint.lat]);
|
|
return p.x>=visible.left&&p.x<=visible.right&&p.y>=visible.top&&p.y<=visible.bottom;
|
|
});
|
|
if (inside) return false;
|
|
const now = Date.now();
|
|
if (!canMoveLiveFollow(this.#lastFollowMoveAt, now)) return false;
|
|
this.#lastFollowMoveAt = now;
|
|
this.container.dataset.cameraMode = 'director';
|
|
if (this.#directorTimer !== undefined) window.clearTimeout(this.#directorTimer);
|
|
this.#directorTimer = window.setTimeout(() => {
|
|
this.#directorTimer = undefined;
|
|
this.container.dataset.cameraMode = 'idle';
|
|
}, 1500);
|
|
const bounds = new maplibregl.LngLatBounds();
|
|
const anchor = endpoints[0]!.lng;
|
|
for (const endpoint of endpoints) bounds.extend([anchor + longitudeDelta(anchor, endpoint.lng), endpoint.lat]);
|
|
const camera = this.map.cameraForBounds(bounds, {
|
|
padding: { top: visible.top, right: viewport.width-visible.right, bottom: viewport.height-visible.bottom, left: visible.left },
|
|
bearing: this.map.getBearing(),
|
|
maxZoom: endpoints.length===1 ? this.map.getZoom() : Math.min(this.#followZoom,this.map.getZoom()),
|
|
});
|
|
if (!camera) return false;
|
|
this.map.easeTo({ ...camera, duration: this.#reducedMotion ? 0 : 1400, essential: false, easeId: 'cartolite-live-follow' });
|
|
return true;
|
|
}
|
|
|
|
beginFollow(): void {
|
|
this.#lastFollowMoveAt = 0;
|
|
this.#followZoom = Math.min(10, this.map.getZoom());
|
|
}
|
|
|
|
followPriority(packet: PacketView): number {
|
|
const bounds = this.map.getBounds();
|
|
return followEndpoints(packet).filter((point) => bounds.contains([point.lng, point.lat])).length;
|
|
}
|
|
|
|
showFollowPacket(packet?: PacketView): void {
|
|
this.#followedPacket = packet;
|
|
const source = this.map.getSource(FOLLOW_SOURCE_ID) as GeoJSONSource | undefined;
|
|
if (!source) return;
|
|
const features: Feature<LineString | Point>[] = [];
|
|
if (packet) {
|
|
const color = payloadColor(packet.payloadType);
|
|
if (packet.mode === 'route') for (const hop of packet.segments) {
|
|
features.push({ type: 'Feature', properties: { color }, geometry: { type: 'LineString', coordinates: [
|
|
[hop.from.lng, hop.from.lat], [hop.from.lng + longitudeDelta(hop.from.lng, hop.to.lng), hop.to.lat],
|
|
] } });
|
|
}
|
|
for (const point of followEndpoints(packet)) features.push({
|
|
type: 'Feature', properties: { color }, geometry: { type: 'Point', coordinates: [point.lng, point.lat] },
|
|
});
|
|
}
|
|
this.container.dataset.followFeatureCount = String(features.length);
|
|
source.setData({ type: 'FeatureCollection', features });
|
|
}
|
|
|
|
setAppearance(preferences: UiPreferences, force = false): void {
|
|
this.#reducedMotion = displayReducedMotion();
|
|
const previous = this.#appearance;
|
|
this.#appearance = { ...preferences };
|
|
this.container.dataset.basemapStyle = preferences.basemap;
|
|
this.container.dataset.nodeLabels = String(preferences.nodeLabels);
|
|
this.container.dataset.mapLabels = String(preferences.mapLabels);
|
|
this.container.dataset.roadsVisible = String(preferences.roads);
|
|
this.container.dataset.routeOpacity = String(preferences.routeOpacity);
|
|
if (!this.#layersReady) return;
|
|
if (force || previous.basemap !== preferences.basemap) {
|
|
for (const layer of cartoVectorStyle(undefined, preferences.basemap).layers) {
|
|
if (!this.map.getLayer(layer.id) || !('paint' in layer)) continue;
|
|
for (const [property, value] of Object.entries(layer.paint ?? {})) {
|
|
this.map.setPaintProperty(layer.id, property as Parameters<maplibregl.Map['setPaintProperty']>[1], value);
|
|
}
|
|
}
|
|
const light = preferences.basemap !== 'dark';
|
|
this.#historicalRouteLayer.setLightBackground(light);
|
|
applyOverlayPalette(this.map, this.#originalOverlayColors, light);
|
|
if (this.#terrain3D) this.#updateSky();
|
|
}
|
|
if (force || previous.basemap !== preferences.basemap || previous.mapLabels !== preferences.mapLabels || previous.roads !== preferences.roads) {
|
|
for (const layer of cartoVectorStyle().layers) {
|
|
const enabled = layer.type === 'symbol' ? preferences.mapLabels : layer.id.endsWith('-roads') ? preferences.roads : true;
|
|
this.map.setLayoutProperty(layer.id, 'visibility', enabled ? 'visible' : 'none');
|
|
}
|
|
}
|
|
this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'visibility', preferences.nodeLabels ? 'visible' : 'none');
|
|
const display = displayPreferences();
|
|
this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'text-padding', display.detail === 'complete' ? 2 : 7);
|
|
this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'text-size', ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, display.textSize === 'large' ? 12 : 10, 12, display.textSize === 'large' ? 15 : 12, 16, display.textSize === 'large' ? 17 : 13]);
|
|
this.map.setPaintProperty(NODE_LABEL_LAYER_ID, 'text-opacity', .96);
|
|
this.#historicalRouteLayer.setOpacity(preferences.routeOpacity);
|
|
this.#historicalRouteLayer.refreshAppearance();
|
|
this.container.dataset.routePreset = displayPreferences().preset;
|
|
if (this.#terrain3D && previous.terrainExaggeration !== preferences.terrainExaggeration) {
|
|
this.map.setTerrain({ source: TERRAIN_SOURCE_ID, exaggeration: preferences.terrainExaggeration });
|
|
}
|
|
this.#updateBuildingLayer();
|
|
if (this.map.getLayer(HILLSHADE_LAYER_ID)) {
|
|
this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-exaggeration', this.#hillshadeStrength());
|
|
const light = preferences.basemap !== 'dark';
|
|
this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-shadow-color', light ? 'rgba(47, 64, 61, 0.55)' : 'rgba(2, 9, 18, 0.86)');
|
|
this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-highlight-color', light ? 'rgba(255, 255, 244, 0.6)' : 'rgba(177, 211, 175, 0.64)');
|
|
}
|
|
this.markRendering();
|
|
}
|
|
|
|
getSelectedNodeID(): string | null { return this.#selectedNodeID; }
|
|
|
|
shouldFollow(packet: PacketView): boolean {
|
|
return packetMatchesFollow(packet, this.#selectedNodeID);
|
|
}
|
|
|
|
findNodes(query: string, limit = 8): NodeSearchResult[] {
|
|
return searchNodes(this.#nodesByID.values(), query, limit);
|
|
}
|
|
|
|
selectNodeByID(nodeID: string, recenter = true): boolean {
|
|
const started = performance.now();
|
|
const node = this.#nodesByID.get(nodeID);
|
|
if (!node) return false;
|
|
this.#clearRouteInspection();
|
|
this.#setSelectedNode(node.id, node.label);
|
|
if (recenter) this.#centerNodeIfNeeded(node);
|
|
this.container.dataset.nodeSelectionApplyMs = (performance.now() - started).toFixed(1);
|
|
return true;
|
|
}
|
|
|
|
clearSelection(): void {
|
|
this.#clearNodeSelection();
|
|
}
|
|
|
|
setRoutesVisible(visible: boolean): void {
|
|
const started = performance.now();
|
|
this.routesVisible = visible;
|
|
this.container.dataset.routesVisible = String(visible);
|
|
this.#historicalRouteLayer.setVisible(visible);
|
|
if (!this.#layersReady) {
|
|
this.container.dataset.routeToggleApplyMs = (performance.now() - started).toFixed(1);
|
|
return;
|
|
}
|
|
const detailSource = this.map.getSource(ROUTE_DETAIL_SOURCE_ID) as GeoJSONSource | undefined;
|
|
const needsHydration = visible && this.#routeDataDirty && Boolean(detailSource);
|
|
this.#updateTerrainRoutes();
|
|
const maxAge = this.#effectiveRouteAgeMS();
|
|
const visualApplied = detailSource
|
|
? applyRouteVisibilityForZoom(this.map, visible && this.#selectedNodeID !== null, maxAge, this.map.getZoom())
|
|
: false;
|
|
const hitApplied = this.#selectedNodeID !== null && applyRouteHitLayerVisibility(this.map, visible);
|
|
const neighborsApplied = this.#selectedNodeID !== null && applyNeighborRingVisibility(this.map, visible);
|
|
if (needsHydration) this.#hydrateRouteSource();
|
|
if (!visible) this.#clearRouteInspection();
|
|
if (!visible) this.map.getCanvas().style.cursor = '';
|
|
if (!needsHydration && (visualApplied || Boolean(detailSource) || hitApplied || neighborsApplied)) this.markRendering();
|
|
this.container.dataset.routeToggleApplyMs = (performance.now() - started).toFixed(1);
|
|
}
|
|
|
|
setHeatmapVisible(visible: boolean): void {
|
|
this.#heatmapVisible = visible;
|
|
this.container.dataset.heatmapVisible = String(visible);
|
|
if (!this.#layersReady) return;
|
|
const source = this.map.getSource(ACTIVITY_HEAT_SOURCE_ID) as GeoJSONSource | undefined;
|
|
if (!source) return;
|
|
if (visible && this.#heatDataDirty) {
|
|
this.#refreshHeatSource();
|
|
} else if (!visible) {
|
|
source.setData(EMPTY_POINTS);
|
|
this.#heatFeatureIDs.clear();
|
|
this.#heatDataDirty = true;
|
|
}
|
|
this.markRendering();
|
|
}
|
|
|
|
setClustersVisible(visible: boolean): void {
|
|
this.#clustersVisible = visible;
|
|
this.container.dataset.clustersVisible = String(visible);
|
|
if (!visible) this.#setHighlightedCluster(null);
|
|
if (!this.#layersReady) return;
|
|
if (applyClusterVisibility(this.map, visible)) this.markRendering();
|
|
}
|
|
|
|
setHillshadeVisible(visible: boolean): void {
|
|
this.#hillshadeVisible = visible;
|
|
this.container.dataset.hillshadeVisible = String(visible);
|
|
if (!this.#layersReady) return;
|
|
if (visible) this.#ensureTerrainLayers();
|
|
if (!this.map.getLayer(HILLSHADE_LAYER_ID)) return;
|
|
this.map.setLayoutProperty(HILLSHADE_LAYER_ID, 'visibility', visible ? 'visible' : 'none');
|
|
this.markRendering();
|
|
}
|
|
|
|
setTerrain3D(enabled: boolean): void {
|
|
this.#terrain3D = enabled;
|
|
this.container.dataset.terrain3d = String(enabled);
|
|
if (!this.#layersReady) return;
|
|
if (enabled) this.#ensureTerrainLayers();
|
|
this.map.setTerrain(enabled ? { source: TERRAIN_SOURCE_ID, exaggeration: this.#appearance.terrainExaggeration } : null);
|
|
this.#updateSky();
|
|
this.map.setLight({ anchor: 'map', color: '#ffffff', intensity: 0.3, position: [1.5, 195, 50] });
|
|
this.#updateBuildingLayer();
|
|
this.#updateTerrainRoutes();
|
|
this.#setTerrainGestures(enabled);
|
|
const camera = this.#cameraOrientation();
|
|
this.container.dataset.cameraPitch = String(camera.pitch);
|
|
if (this.#reducedMotion) this.map.jumpTo(camera);
|
|
else this.map.easeTo({ ...camera, duration: 680, essential: false });
|
|
this.markRendering();
|
|
}
|
|
|
|
setBuildingsVisible(visible: boolean): void {
|
|
this.#appearance.buildings = visible;
|
|
this.#updateBuildingLayer();
|
|
}
|
|
|
|
setCameraPitch(pitch: number): void {
|
|
this.#appearance.terrainPitch = clamp(pitch, 0, 65);
|
|
if (this.#terrain3D) {
|
|
this.map.jumpTo({ pitch: this.#appearance.terrainPitch });
|
|
this.container.dataset.cameraPitch = String(this.#appearance.terrainPitch);
|
|
}
|
|
}
|
|
|
|
resetNorth(): void {
|
|
this.#appearance.terrainBearing = 0;
|
|
if (this.#reducedMotion) this.map.jumpTo({ bearing: 0 });
|
|
else this.map.easeTo({ bearing: 0, duration: 400 });
|
|
}
|
|
|
|
#updateBuildingLayer(): void {
|
|
this.container.dataset.buildingsVisible = String(this.#appearance.buildings);
|
|
this.container.dataset.buildingExtrusions = String(this.#appearance.buildings && this.#terrain3D && viewClass() === 'desktop');
|
|
if (!this.#layersReady) return;
|
|
this.#buildingSourceID = updateBuildings(this.map, this.#appearance.buildings, this.#terrain3D, viewClass() === 'desktop', this.#appearance.basemap !== 'dark', basemapProvider() === 'openfreemap' ? 'carto' : undefined);
|
|
}
|
|
|
|
#updateSky(): void {
|
|
const light = this.#appearance.basemap !== 'dark';
|
|
this.map.setSky(this.#terrain3D ? { 'sky-color': light ? '#cfe1e8' : '#14242e', 'horizon-color': light ? '#eef1ee' : '#29424a', 'fog-color': light ? '#dce7e5' : '#14242e', 'horizon-fog-blend': 0.15 } : {});
|
|
}
|
|
|
|
|
|
#hillshadeStrength(): ExpressionSpecification {
|
|
const strength = this.#appearance.relief;
|
|
return ['interpolate', ['linear'], ['zoom'], 3, strength * 0.8, 10, strength * 0.85, 14, strength * 0.42, 18, strength * 0.28];
|
|
}
|
|
|
|
setRouteWindow(window: RouteWindow): void {
|
|
if (this.#routeWindow === window) return;
|
|
const started = performance.now();
|
|
this.#routeWindow = window;
|
|
if (!this.#layersReady) {
|
|
this.#emitRouteWindowChange();
|
|
this.container.dataset.routeWindowApplyMs = (performance.now() - started).toFixed(1);
|
|
return;
|
|
}
|
|
this.#applyRouteTimeState();
|
|
if (this.#selectedNodeID) {
|
|
this.#updateFocusData();
|
|
this.#applyFocusState(false);
|
|
}
|
|
if (this.#hoveredRouteID && !this.#isSelectedRouteInspectable(this.#hoveredRouteID)) this.#clearRouteInspection();
|
|
this.#emitRouteWindowChange();
|
|
this.container.dataset.routeWindowApplyMs = (performance.now() - started).toFixed(1);
|
|
this.markRendering();
|
|
}
|
|
|
|
destroy(): void {
|
|
this.#routeHydrationEpoch += 1;
|
|
this.#routeHydrating = false;
|
|
this.renderEpoch += 1;
|
|
window.clearInterval(this.#freshnessTimer);
|
|
if (this.#routeHydrationTimer !== undefined) window.clearTimeout(this.#routeHydrationTimer);
|
|
if (this.#directorTimer !== undefined) window.clearTimeout(this.#directorTimer);
|
|
if (this.#clusterFlashTimer !== undefined) window.clearTimeout(this.#clusterFlashTimer);
|
|
this.#closeInspector(false);
|
|
document.removeEventListener('keydown', this.#handleKeyDown);
|
|
this.map.off('zoom', this.#updateRouteRepresentation);
|
|
this.map.off('zoomend', this.#handleZoomEnd);
|
|
this.map.off('resize', this.#handleInspectorResize);
|
|
this.map.off('webglcontextrestored', this.#handleWebGLContextRestored);
|
|
this.map.remove();
|
|
}
|
|
|
|
#ensureTerrainLayers(): void {
|
|
if (this.#terrainLayersReady) return;
|
|
for (const source of [TERRAIN_SOURCE_ID, HILLSHADE_SOURCE_ID]) {
|
|
if (this.map.getSource(source)) continue;
|
|
this.map.addSource(source, {
|
|
type: 'raster-dem',
|
|
url: TERRAIN_TILEJSON_URL,
|
|
tileSize: 512,
|
|
// Terrain must not overzoom the lower-resolution vector/GeoJSON
|
|
// overlays. Hillshade can independently use the provider's finer data.
|
|
maxzoom: source === TERRAIN_SOURCE_ID ? 12 : 14,
|
|
encoding: 'terrarium',
|
|
attribution: 'Terrain © <a href="https://mapterhorn.com/attribution">Mapterhorn</a>'
|
|
});
|
|
}
|
|
if (!this.map.getLayer(HILLSHADE_LAYER_ID)) {
|
|
const before = this.map.getLayer('basemap-country-boundary') ? 'basemap-country-boundary' : undefined;
|
|
this.map.addLayer({
|
|
id: HILLSHADE_LAYER_ID,
|
|
type: 'hillshade',
|
|
source: HILLSHADE_SOURCE_ID,
|
|
layout: { visibility: this.#hillshadeVisible ? 'visible' : 'none' },
|
|
paint: {
|
|
'hillshade-illumination-anchor': 'map',
|
|
'hillshade-method': 'multidirectional',
|
|
'hillshade-illumination-direction': [270, 315, 0, 45],
|
|
'hillshade-illumination-altitude': [35, 35, 35, 35],
|
|
'hillshade-exaggeration': this.#hillshadeStrength(),
|
|
'hillshade-shadow-color': this.#appearance.basemap === 'dark' ? 'rgba(2, 9, 18, 0.86)' : 'rgba(47, 64, 61, 0.55)',
|
|
'hillshade-highlight-color': this.#appearance.basemap === 'dark' ? 'rgba(177, 211, 175, 0.64)' : 'rgba(255, 255, 244, 0.6)',
|
|
'hillshade-accent-color': 'rgba(47, 86, 75, 0.54)'
|
|
}
|
|
}, before);
|
|
}
|
|
this.#terrainLayersReady = true;
|
|
this.container.dataset.terrainReady = 'true';
|
|
}
|
|
|
|
#setTerrainGestures(enabled: boolean): void {
|
|
if (enabled) {
|
|
this.map.dragRotate.enable();
|
|
this.map.touchZoomRotate.enableRotation();
|
|
this.map.touchPitch.enable();
|
|
return;
|
|
}
|
|
this.map.dragRotate.disable();
|
|
this.map.touchZoomRotate.disableRotation();
|
|
this.map.touchPitch.disable();
|
|
}
|
|
|
|
#cameraOrientation(): { bearing: number; pitch: number } {
|
|
return this.#terrain3D ? { bearing: this.#appearance.terrainBearing, pitch: this.#appearance.terrainPitch } : { bearing: 0, pitch: 0 };
|
|
}
|
|
|
|
#updateTerrainRoutes(): void {
|
|
this.container.dataset.routeSurface = this.#terrain3D ? 'terrain' : 'flat';
|
|
this.#historicalRouteLayer.setVisible(this.routesVisible);
|
|
}
|
|
|
|
#updateRouteRepresentation = (): void => {
|
|
const representation = routeRepresentationForZoom(this.map.getZoom());
|
|
if (this.container.dataset.routeRepresentation === representation) return;
|
|
this.container.dataset.routeRepresentation = representation;
|
|
this.options.onRouteRepresentationChange?.(representation);
|
|
};
|
|
|
|
#handleZoomEnd = (): void => {
|
|
if (!this.#layersReady) {
|
|
this.#emitRouteWindowChange();
|
|
return;
|
|
}
|
|
const visibilityApplied = applyRouteVisibilityForZoom(
|
|
this.map,
|
|
this.routesVisible && this.#selectedNodeID !== null,
|
|
this.#effectiveRouteAgeMS(),
|
|
this.map.getZoom()
|
|
);
|
|
if (this.#routeWindow !== 'auto') {
|
|
this.#applyRouteTimeState();
|
|
this.#emitRouteWindowChange();
|
|
if (visibilityApplied) this.markRendering();
|
|
return;
|
|
}
|
|
this.#applyRouteTimeState();
|
|
if (this.#selectedNodeID) {
|
|
this.#updateFocusData();
|
|
this.#applyFocusState(false);
|
|
}
|
|
this.#emitRouteWindowChange();
|
|
this.markRendering();
|
|
};
|
|
|
|
#refreshRouteClock(): void {
|
|
const now = Date.now();
|
|
this.#refreshRouteAgeBands(now);
|
|
this.#applyRouteTimeState(now, true);
|
|
if (this.#selectedNodeID) {
|
|
this.#updateFocusData();
|
|
this.#applyFocusState(false);
|
|
}
|
|
if (this.#hoveredRouteID && !this.#isSelectedRouteInspectable(this.#hoveredRouteID)) this.#clearRouteInspection();
|
|
this.markRendering();
|
|
}
|
|
|
|
#refreshRouteAgeBands(now: number): void {
|
|
let changed = false;
|
|
for (const route of this.#routesByID.values()) {
|
|
const feature = this.#routeDetailFeatures.get(route.id);
|
|
if (!feature) continue;
|
|
const age = Math.max(0, now - route.lastHeard);
|
|
const expired = age > ROUTE_MAX_AGE_MS;
|
|
const previousBand = Number(feature.properties?.windowBand);
|
|
if (!expired && previousBand === routeWindowBand(age)) continue;
|
|
this.#dirtyRouteIDs.add(route.id);
|
|
changed = true;
|
|
}
|
|
if (!changed) return;
|
|
this.#routeDataDirty = true;
|
|
if (!this.#routeHydrating && this.map.getSource(ROUTE_DETAIL_SOURCE_ID)) this.#hydrateRouteSource(now);
|
|
}
|
|
|
|
#applyRouteTimeState(now = Date.now(), refreshClock = false): void {
|
|
const maxAge = this.#effectiveRouteAgeMS();
|
|
if (refreshClock || this.#routeClock === 0) this.#routeClock = now;
|
|
this.#historicalRouteLayer.setMaximumBand(routeWindowBand(maxAge));
|
|
this.container.dataset.eligibleRoutes = String(countEligibleRoutes(this.#routesByID.values(), this.#nodesByID, this.#routeClock, maxAge));
|
|
// Compatibility diagnostics: history consists only of exact segments.
|
|
for (const key of ['nationalRouteTrunks', 'regionalRouteTrunks', 'nationalRoutesRepresented', 'regionalRoutesRepresented']) this.container.dataset[key] = '0';
|
|
this.#updateTerrainRoutes();
|
|
}
|
|
|
|
#emitRouteWindowChange(): void {
|
|
this.options.onRouteWindowChange?.(routeWindowLabel('auto', this.map.getZoom()));
|
|
}
|
|
|
|
#effectiveRouteAgeMS(): number {
|
|
return effectiveRouteWindowMS(this.#routeWindow, this.map.getZoom());
|
|
}
|
|
|
|
#installLayers(): void {
|
|
if (this.#hillshadeVisible || this.#terrain3D) this.#ensureTerrainLayers();
|
|
this.map.addSource(ACTIVITY_HEAT_SOURCE_ID, { type: 'geojson', data: EMPTY_POINTS, maxzoom: 14 });
|
|
PACKET_KINDS.forEach((kind, index) => this.map.addLayer({
|
|
id: HEATMAP_LAYER_IDS[index]!,
|
|
type: 'heatmap',
|
|
source: ACTIVITY_HEAT_SOURCE_ID,
|
|
filter: ['==', ['get', 'kind'], kind],
|
|
paint: {
|
|
'heatmap-weight': [
|
|
'interpolate', ['linear'], ['number', ['get', 'weight'], 0],
|
|
0, 0,
|
|
0.2, 0.18,
|
|
0.55, 0.8,
|
|
1, 1.5
|
|
],
|
|
'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 3, 0.68, 7, 1.02, 10, 1.28, 16, 1.42],
|
|
'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 3, 16, 6, 24, 9, 32, 13, 41, 16, 46],
|
|
'heatmap-color': heatmapColorExpression(PACKET_KIND_COLORS[kind]),
|
|
'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.78, 7, 0.68, 10, 0.46, 14, 0.22, 16, 0.12]
|
|
}
|
|
}));
|
|
this.map.addSource(ROUTE_DETAIL_SOURCE_ID, { type: 'geojson', data: EMPTY_LINES, maxzoom: 16 });
|
|
this.map.addSource(ROUTE_FOCUS_SOURCE_ID, { type: 'geojson', data: EMPTY_LINES, maxzoom: 16 });
|
|
this.#applyRouteTimeState(Date.now(), true);
|
|
const exactVisibility = this.routesVisible && this.#selectedNodeID !== null ? 'visible' : 'none';
|
|
this.#historicalRouteLayer.setVisible(this.routesVisible);
|
|
this.#historicalRouteLayer.setMaximumBand(routeWindowBand(this.#effectiveRouteAgeMS()));
|
|
this.map.addLayer(this.#historicalRouteLayer);
|
|
this.map.addLayer({
|
|
id: ROUTE_FOCUS_LAYER_IDS[0],
|
|
type: 'line',
|
|
source: ROUTE_FOCUS_SOURCE_ID,
|
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: exactVisibility },
|
|
paint: {
|
|
'line-color': routeColorExpression(),
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.5, 6.5, ['*', ['get', 'glowWidth'], 0.75], 10, ['*', ['get', 'glowWidth'], 1.5], 14, ['*', ['get', 'glowWidth'], 2.4]],
|
|
'line-opacity': ['*', ['get', 'opacity'], 0.78],
|
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.2, 6.5, 0.6, 12, 2.5]
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: ROUTE_FOCUS_LAYER_IDS[1],
|
|
type: 'line',
|
|
source: ROUTE_FOCUS_SOURCE_ID,
|
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: exactVisibility },
|
|
paint: {
|
|
'line-color': routeColorExpression(),
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 6.5, ['*', ['get', 'width'], 1.12], 10, ['*', ['get', 'width'], 1.45], 14, ['*', ['get', 'width'], 1.7]],
|
|
'line-opacity': 0.98
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: ROUTE_HOVER_LAYER_IDS[0],
|
|
type: 'line',
|
|
source: ROUTE_FOCUS_SOURCE_ID,
|
|
filter: routeIDFilter(null),
|
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' },
|
|
paint: {
|
|
'line-color': routeColorExpression(),
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 4, 8, ['*', ['get', 'glowWidth'], 1.8], 14, ['*', ['get', 'glowWidth'], 2.15]],
|
|
'line-opacity': 0.62,
|
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.2, 7, 0.8, 12, 2.5]
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: ROUTE_HOVER_LAYER_IDS[1],
|
|
type: 'line',
|
|
source: ROUTE_FOCUS_SOURCE_ID,
|
|
filter: routeIDFilter(null),
|
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' },
|
|
paint: {
|
|
'line-color': routeColorExpression(),
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1.1, 8, ['*', ['get', 'width'], 1.28], 14, ['*', ['get', 'width'], 1.65]],
|
|
'line-opacity': 1
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: ROUTE_HIT_LAYER_ID,
|
|
type: 'line',
|
|
source: ROUTE_FOCUS_SOURCE_ID,
|
|
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: 'none' },
|
|
paint: {
|
|
'line-color': '#ffffff',
|
|
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 13, 13, 18],
|
|
'line-opacity': 0.001
|
|
}
|
|
});
|
|
applyRouteSelectionFilter(this.map, this.#selectedNodeID);
|
|
applyRouteHitLayerVisibility(this.map, this.routesVisible && this.#selectedNodeID !== null);
|
|
applyRouteHoverFilter(this.map, null);
|
|
this.map.addSource(NODE_CLUSTER_SOURCE_ID, {
|
|
type: 'geojson',
|
|
data: EMPTY_POINTS,
|
|
cluster: true,
|
|
clusterMaxZoom: 8,
|
|
clusterRadius: 46,
|
|
maxzoom: 14
|
|
});
|
|
this.map.addSource(NODE_SOURCE_ID, { type: 'geojson', data: EMPTY_POINTS, maxzoom: 16 });
|
|
this.map.addLayer({
|
|
id: 'clusters-glow',
|
|
type: 'circle',
|
|
source: NODE_CLUSTER_SOURCE_ID,
|
|
maxzoom: DETAIL_ZOOM,
|
|
filter: ['has', 'point_count'],
|
|
paint: {
|
|
'circle-color': '#32c8bb',
|
|
'circle-radius': ['interpolate', ['linear'], ['get', 'point_count'], 2, 13, 20, 18, 100, 23, 500, 28],
|
|
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.1, 5.5, 0.18, DETAIL_ZOOM, 0.04],
|
|
'circle-blur': 0.64
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: 'clusters',
|
|
type: 'circle',
|
|
source: NODE_CLUSTER_SOURCE_ID,
|
|
maxzoom: DETAIL_ZOOM,
|
|
filter: ['has', 'point_count'],
|
|
paint: {
|
|
'circle-color': '#08272c',
|
|
'circle-stroke-color': '#48d5c7',
|
|
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 3, 0.8, DETAIL_ZOOM, 1.35],
|
|
'circle-radius': ['interpolate', ['linear'], ['get', 'point_count'], 2, 8, 20, 11, 100, 14.5, 500, 18],
|
|
'circle-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.84, 6.2, 0.98, DETAIL_ZOOM, 0.5]
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: CLUSTER_HIGHLIGHT_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_CLUSTER_SOURCE_ID,
|
|
maxzoom: DETAIL_ZOOM,
|
|
filter: clusterIDFilter(null),
|
|
paint: {
|
|
'circle-color': 'rgba(0,0,0,0)',
|
|
'circle-radius': ['interpolate', ['linear'], ['get', 'point_count'], 2, 12, 20, 15, 100, 19, 500, 23],
|
|
'circle-stroke-color': '#dffffb',
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-opacity': 0.9,
|
|
'circle-blur': 0.2
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: 'cluster-count',
|
|
type: 'symbol',
|
|
source: NODE_CLUSTER_SOURCE_ID,
|
|
maxzoom: DETAIL_ZOOM,
|
|
filter: ['has', 'point_count'],
|
|
layout: {
|
|
'text-field': ['get', 'point_count_abbreviated'],
|
|
'text-font': basemapFontStack(),
|
|
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 8.5, DETAIL_ZOOM, 10.5]
|
|
},
|
|
paint: {
|
|
'text-color': '#e5fffc',
|
|
'text-halo-color': '#061216',
|
|
'text-halo-width': 1
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: NODE_GLOW_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: NODE_BASE_FILTER,
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 8, 10, 11, 14, 15],
|
|
'circle-color': ['get', 'color'],
|
|
'circle-opacity': nodeGlowOpacity(false, []),
|
|
'circle-blur': 0.72
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: NEIGHBOR_NODE_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: nodeIDFilter([]),
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 7.5, 10, 10.5, 14, 14],
|
|
'circle-color': 'rgba(0,0,0,0)',
|
|
'circle-stroke-color': '#f3b844',
|
|
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 1.2, 12, 2.1],
|
|
'circle-stroke-opacity': ['*', ['get', 'opacity'], 0.94]
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: SELECTED_NODE_OUTER_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: selectedNodeFilter(this.#selectedNodeID),
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 10, 10, 14, 14, 18],
|
|
'circle-color': 'rgba(0,0,0,0)',
|
|
'circle-stroke-color': '#f2bd55',
|
|
'circle-stroke-width': 2.4,
|
|
'circle-stroke-opacity': 0.62,
|
|
'circle-blur': 0.7
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: SELECTED_NODE_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: selectedNodeFilter(this.#selectedNodeID),
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 7.2, 10, 10.2, 14, 13.5],
|
|
'circle-color': 'rgba(242,189,85,0.12)',
|
|
'circle-stroke-color': '#fff0b8',
|
|
'circle-stroke-width': 2.7,
|
|
'circle-stroke-opacity': 0.96
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: NODE_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: NODE_BASE_FILTER,
|
|
layout: {
|
|
'circle-sort-key': ['-', 100, ['get', 'labelPriority']]
|
|
},
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 3.6, 9, 4.6, 12, 6.4, 16, 7.6],
|
|
'circle-color': ['get', 'color'],
|
|
'circle-stroke-color': ['case', ['get', 'observer'], '#f5cf76', '#bce9e5'],
|
|
'circle-stroke-width': ['case', ['get', 'observer'], 1.6, 0.9],
|
|
'circle-opacity': nodeOpacity(false, [])
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: NODE_CORE_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: NODE_BASE_FILTER,
|
|
layout: {
|
|
'circle-sort-key': ['-', 100, ['get', 'labelPriority']]
|
|
},
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 1.15, 10, 1.65, 14, 2.35],
|
|
'circle-color': '#edfffd',
|
|
'circle-opacity': nodeCoreOpacity(false, [])
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: NODE_LABEL_LAYER_ID,
|
|
type: 'symbol',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.05,
|
|
filter: NODE_BASE_FILTER,
|
|
layout: {
|
|
'text-field': ['get', 'mapLabel'],
|
|
'text-font': basemapFontStack(),
|
|
'text-size': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 8.6, 9, 9.8, 12, 11.2, 16, 12.4],
|
|
'text-variable-anchor': ['top', 'bottom', 'left', 'right'],
|
|
'text-radial-offset': 0.82,
|
|
'text-justify': 'auto',
|
|
'text-padding': 3,
|
|
'text-max-width': 12,
|
|
'symbol-sort-key': ['get', 'labelPriority'],
|
|
'text-allow-overlap': false,
|
|
'text-ignore-placement': false
|
|
},
|
|
paint: {
|
|
'text-color': ['case', ['get', 'observer'], '#f6d77f', '#d2e0ef'],
|
|
'text-halo-color': '#02070b',
|
|
'text-halo-width': 1.35,
|
|
'text-halo-blur': 0.3,
|
|
'text-opacity': [
|
|
'interpolate', ['linear'], ['zoom'],
|
|
DETAIL_ZOOM, 0.78,
|
|
9.5, 0.9,
|
|
11, 0.96
|
|
]
|
|
}
|
|
});
|
|
this.map.addLayer({
|
|
id: NODE_HIT_LAYER_ID,
|
|
type: 'circle',
|
|
source: NODE_SOURCE_ID,
|
|
minzoom: DETAIL_ZOOM - 0.15,
|
|
filter: NODE_BASE_FILTER,
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 22, 10, 24, 14, 26],
|
|
'circle-color': '#ffffff',
|
|
'circle-opacity': 0.001
|
|
}
|
|
});
|
|
|
|
this.#applyFocusState();
|
|
applyClusterVisibility(this.map, this.#clustersVisible);
|
|
|
|
this.map.on('mousemove', NODE_HIT_LAYER_ID, (event) => this.#showNodeTooltip(event));
|
|
this.map.on('mouseleave', NODE_HIT_LAYER_ID, () => {
|
|
// Touch browsers can synthesize this after a route tap. Do not let a
|
|
// late node leave hide the route tooltip that has just replaced it.
|
|
if (this.tooltip.dataset.kind === 'node') this.#hideTooltip();
|
|
});
|
|
this.map.on('mousemove', ROUTE_HIT_LAYER_ID, (event) => {
|
|
if (!this.#routeInspectionPinned) this.#showRouteTooltip(event);
|
|
});
|
|
this.map.on('mouseleave', ROUTE_HIT_LAYER_ID, () => {
|
|
this.map.getCanvas().style.cursor = '';
|
|
if (!this.#routeInspectionPinned) this.#clearRouteInspection();
|
|
});
|
|
this.map.on('mousemove', 'clusters', (event) => this.#highlightCluster(event));
|
|
this.map.on('mouseleave', 'clusters', () => {
|
|
if (this.#clusterFlashTimer === undefined) this.#setHighlightedCluster(null);
|
|
});
|
|
this.map.on('click', (event) => this.#handleMapClick(event));
|
|
this.map.on('mousemove', (event) => {
|
|
if (!this.#terrain3D || !this.#selectedNodeID) return;
|
|
if (!this.#showRouteTooltip(event) && this.tooltip.dataset.kind === 'route' && !this.#routeInspectionPinned) this.#clearRouteInspection();
|
|
});
|
|
this.map.on('movestart', () => {
|
|
this.#hideTooltip();
|
|
this.#clearRouteInspection();
|
|
if (this.#clusterFlashTimer === undefined) this.#setHighlightedCluster(null);
|
|
});
|
|
for (const layer of [NODE_HIT_LAYER_ID, 'clusters']) {
|
|
this.map.on('mouseenter', layer, () => { this.map.getCanvas().style.cursor = 'pointer'; });
|
|
this.map.on('mouseleave', layer, () => { this.map.getCanvas().style.cursor = ''; });
|
|
}
|
|
this.map.on('mouseenter', ROUTE_HIT_LAYER_ID, () => { this.map.getCanvas().style.cursor = 'pointer'; });
|
|
this.map.addSource(FOLLOW_SOURCE_ID, { type: 'geojson', data: EMPTY_LINES });
|
|
this.map.addLayer({
|
|
id: 'live-follow-line', type: 'line', source: FOLLOW_SOURCE_ID,
|
|
filter: ['==', ['geometry-type'], 'LineString'],
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
paint: { 'line-color': ['get', 'color'], 'line-width': ['interpolate', ['linear'], ['zoom'], 2, 1.1, 12, 2.3], 'line-opacity': 0.9 },
|
|
});
|
|
this.map.addLayer({
|
|
id: 'live-follow-nodes', type: 'circle', source: FOLLOW_SOURCE_ID,
|
|
filter: ['==', ['geometry-type'], 'Point'],
|
|
paint: { 'circle-color': ['get', 'color'], 'circle-radius': 4, 'circle-stroke-color': '#19363b', 'circle-stroke-width': 1.5 },
|
|
});
|
|
this.#layersReady = true;
|
|
this.setAppearance(this.#appearance, true);
|
|
this.showFollowPacket(this.#followedPacket);
|
|
if (this.#terrain3D) this.setTerrain3D(true);
|
|
this.render(this.#lastState, { reset: true }, true);
|
|
}
|
|
|
|
private markRendering(sourceIDs?: readonly string[]): void {
|
|
this.container.dataset.renderState = 'rendering';
|
|
for (const sourceID of sourceIDs ?? []) this.renderingSources.add(sourceID);
|
|
// One readiness loop survives new packets; a busy feed must not keep
|
|
// cancelling the two-frame settle check or queue duplicate frame callbacks.
|
|
if (this.renderingScheduled) return;
|
|
this.renderingScheduled = true;
|
|
const epoch = ++this.renderEpoch;
|
|
let settledFrames = 0;
|
|
// Basemap tiles and live packets can keep MapLibre's global loaded/idle
|
|
// state false indefinitely. Gate readiness on CartoLite's own sources.
|
|
const settle = (): void => {
|
|
if (epoch !== this.renderEpoch) return;
|
|
const sourcesSettled = [...this.renderingSources].every((sourceID) => (
|
|
Boolean(this.map.getSource(sourceID)) && this.map.isSourceLoaded(sourceID)
|
|
));
|
|
const awaitingVisibleRoutes = this.routesVisible
|
|
&& (!this.#lastState || this.container.dataset.exactRoutesReady !== 'true');
|
|
if (awaitingVisibleRoutes || (this.routesVisible && this.#routeHydrating) || !sourcesSettled) {
|
|
settledFrames = 0;
|
|
window.requestAnimationFrame(settle);
|
|
return;
|
|
}
|
|
settledFrames += 1;
|
|
if (settledFrames < 2) {
|
|
window.requestAnimationFrame(settle);
|
|
return;
|
|
}
|
|
this.renderingSources.clear();
|
|
this.renderingScheduled = false;
|
|
this.container.dataset.renderState = 'idle';
|
|
};
|
|
window.requestAnimationFrame(settle);
|
|
}
|
|
|
|
async #expandCluster(event: MapMouseEvent): Promise<void> {
|
|
const feature = this.map.queryRenderedFeatures(event.point, { layers: ['clusters'] })[0];
|
|
const clusterId = Number(feature?.properties?.cluster_id);
|
|
if (!Number.isFinite(clusterId)) return;
|
|
this.#flashCluster(clusterId);
|
|
const source = this.map.getSource(NODE_CLUSTER_SOURCE_ID) as GeoJSONSource;
|
|
const zoom = await source.getClusterExpansionZoom(clusterId);
|
|
const coordinates = feature?.geometry.type === 'Point' ? feature.geometry.coordinates : undefined;
|
|
if (coordinates && typeof coordinates[0] === 'number' && typeof coordinates[1] === 'number') {
|
|
const center: [number, number] = [coordinates[0], coordinates[1]];
|
|
if (this.#reducedMotion) {
|
|
this.map.jumpTo({ center, zoom });
|
|
} else {
|
|
this.map.easeTo({ center, zoom, duration: 460, essential: false });
|
|
}
|
|
}
|
|
}
|
|
|
|
#handleMapClick(event: MapMouseEvent): void {
|
|
if (this.map.queryRenderedFeatures(event.point, { layers: [NODE_HIT_LAYER_ID] }).length > 0) {
|
|
this.#selectNode(event);
|
|
return;
|
|
}
|
|
if (this.map.queryRenderedFeatures(event.point, { layers: ['clusters'] }).length > 0) {
|
|
this.#clearNodeSelection();
|
|
void this.#expandCluster(event);
|
|
return;
|
|
}
|
|
if (this.#showRouteTooltip(event, true)) return;
|
|
this.#clearNodeSelection();
|
|
}
|
|
|
|
#selectNode(event: MapMouseEvent): void {
|
|
const feature = this.map.queryRenderedFeatures(event.point, { layers: [NODE_HIT_LAYER_ID] })[0];
|
|
if (!feature) return;
|
|
const nodeID = String(feature.properties?.id ?? feature.id ?? '');
|
|
if (!nodeID) return;
|
|
this.#clearRouteInspection();
|
|
this.#setSelectedNode(nodeID, String(feature.properties?.label ?? 'MeshCore node'));
|
|
this.#hideTooltip();
|
|
}
|
|
|
|
#clearNodeSelection(): void {
|
|
this.#setSelectedNode(null);
|
|
this.#hideTooltip();
|
|
this.map.getCanvas().style.cursor = '';
|
|
}
|
|
|
|
#setSelectedNode(nodeID: string | null, label = ''): void {
|
|
if (this.#selectedNodeID === nodeID && (!nodeID || !label || label === this.#selectedNodeLabel)) return;
|
|
this.#clearRouteInspection();
|
|
this.#selectedNodeID = nodeID;
|
|
this.#selectedNodeLabel = nodeID ? label : '';
|
|
this.container.dataset.selectedNodeId = nodeID ?? '';
|
|
rememberNode(nodeID);
|
|
this.#updateFocusData();
|
|
this.#applyFocusState();
|
|
if (nodeID === null && this.tooltip.dataset.kind === 'route') this.#hideTooltip();
|
|
this.markRendering();
|
|
}
|
|
|
|
#updateFocusData(): void {
|
|
const now = Date.now();
|
|
const maxAge = this.#effectiveRouteAgeMS();
|
|
const routes = this.#adjacentRoutes(this.#selectedNodeID).filter((route) => isRecentNeighborRoute(route, now, maxAge));
|
|
this.#neighborNodeIDs = neighborNodeIDs(routes, this.#selectedNodeID);
|
|
this.container.dataset.neighborRouteCount = String(routes.length);
|
|
const focusSource = this.map.getSource(ROUTE_FOCUS_SOURCE_ID) as GeoJSONSource | undefined;
|
|
if (focusSource) focusSource.setData(routeCollection(routes, this.#nodesByID, now, maxAge));
|
|
this.container.dataset.focusedRouteCount = String(routes.length);
|
|
const stateLabel = this.#selectedNodeID ? this.#nodesByID.get(this.#selectedNodeID)?.label : undefined;
|
|
if (stateLabel) this.#selectedNodeLabel = stateLabel;
|
|
this.#emitFocusChange();
|
|
this.#renderNodeInspector();
|
|
}
|
|
|
|
#emitFocusChange(): void {
|
|
const focus = this.#selectedNodeID
|
|
? { label: this.#selectedNodeLabel || 'MeshCore node', neighborCount: this.#neighborNodeIDs.length }
|
|
: null;
|
|
const signature = focus ? `${this.#selectedNodeID}:${focus.label}:${focus.neighborCount}` : '';
|
|
if (signature === this.#lastFocusSignature) return;
|
|
this.#lastFocusSignature = signature;
|
|
this.options.onFocusChange?.(focus);
|
|
}
|
|
|
|
#applyFocusState(updateRouteFilter = true): void {
|
|
const focusIDs = this.#selectedNodeID ? [this.#selectedNodeID, ...this.#neighborNodeIDs] : [];
|
|
applyRouteVisibilityForZoom(this.map, this.routesVisible && this.#selectedNodeID !== null, this.#effectiveRouteAgeMS(), this.map.getZoom());
|
|
if (updateRouteFilter) applyRouteSelectionFilter(this.map, this.#selectedNodeID);
|
|
applySelectedNodeFilter(this.map, this.#selectedNodeID);
|
|
applyNodeFocus(this.map, this.#selectedNodeID, focusIDs, this.#neighborNodeIDs);
|
|
applyHeatmapFocus(this.map, focusIDs);
|
|
applyRouteHitLayerVisibility(this.map, this.routesVisible && this.#selectedNodeID !== null);
|
|
applyNeighborRingVisibility(this.map, this.routesVisible && this.#selectedNodeID !== null);
|
|
}
|
|
|
|
#showNodeTooltip(event: MapMouseEvent): void {
|
|
const feature = this.map.queryRenderedFeatures(event.point, { layers: [NODE_HIT_LAYER_ID] })[0];
|
|
if (!feature) return;
|
|
const properties = feature.properties ?? {};
|
|
const role = String(properties.role ?? 'unknown').replace('_', ' ');
|
|
const seen = Number(properties.lastSeen);
|
|
this.#presentTooltip(
|
|
event,
|
|
String(properties.label ?? 'MeshCore node'),
|
|
`${role}${properties.observer ? ' · observer' : ''}${Number.isFinite(seen) ? ` · ${relativeTime(seen)}` : ''}`,
|
|
'node'
|
|
);
|
|
}
|
|
|
|
#showRouteTooltip(event: MapMouseEvent, pin = false): boolean {
|
|
if (!this.routesVisible || !this.#selectedNodeID) return false;
|
|
if (this.map.queryRenderedFeatures(event.point, { layers: [NODE_HIT_LAYER_ID] }).length > 0) return false;
|
|
const feature = this.map.queryRenderedFeatures(event.point, { layers: [ROUTE_HIT_LAYER_ID] })
|
|
.find((candidate) => this.#isSelectedRouteInspectable(String(candidate.properties?.id ?? candidate.id ?? '')));
|
|
const terrainRouteID = this.#historicalRouteLayer.pick(event.point, (id) => this.#isSelectedRouteInspectable(id));
|
|
if (!feature && !terrainRouteID) return false;
|
|
const properties = feature?.properties ?? {};
|
|
const route = this.#routesByID.get(terrainRouteID ?? String(properties.id ?? feature?.id ?? ''));
|
|
if (!route) return false;
|
|
const from = this.#nodesByID.get(route.fromId);
|
|
const to = this.#nodesByID.get(route.toId);
|
|
if (!from || !to) return false;
|
|
this.#routeInspectionPinned = pin;
|
|
this.#setHoveredRoute(route.id);
|
|
this.#presentTooltip(
|
|
event,
|
|
`${from.label} ↔ ${to.label}`,
|
|
`${route.lastKind} · ${observationTotal(route.packetCount)} · heard ${relativeTime(route.lastHeard)}`,
|
|
'route'
|
|
);
|
|
return true;
|
|
}
|
|
|
|
#setHoveredRoute(routeID: string | null): void {
|
|
if (this.#hoveredRouteID === routeID) return;
|
|
this.#hoveredRouteID = routeID;
|
|
this.container.dataset.hoveredRouteId = routeID ?? '';
|
|
applyRouteHoverFilter(this.map, this.routesVisible && this.#selectedNodeID ? routeID : null);
|
|
}
|
|
|
|
#clearRouteInspection(): void {
|
|
this.#routeInspectionPinned = false;
|
|
this.#setHoveredRoute(null);
|
|
if (this.tooltip.dataset.kind === 'route') this.#hideTooltip();
|
|
}
|
|
|
|
#highlightCluster(event: MapMouseEvent): void {
|
|
const feature = this.map.queryRenderedFeatures(event.point, { layers: ['clusters'] })[0];
|
|
const clusterID = Number(feature?.properties?.cluster_id);
|
|
this.#setHighlightedCluster(Number.isFinite(clusterID) ? clusterID : null);
|
|
}
|
|
|
|
#setHighlightedCluster(clusterID: number | null): void {
|
|
if (this.#highlightedClusterID === clusterID) return;
|
|
this.#highlightedClusterID = clusterID;
|
|
applyClusterHighlightFilter(this.map, clusterID);
|
|
}
|
|
|
|
#flashCluster(clusterID: number): void {
|
|
if (this.#clusterFlashTimer !== undefined) window.clearTimeout(this.#clusterFlashTimer);
|
|
this.#setHighlightedCluster(clusterID);
|
|
this.#clusterFlashTimer = window.setTimeout(() => {
|
|
this.#clusterFlashTimer = undefined;
|
|
this.#setHighlightedCluster(null);
|
|
}, 540);
|
|
}
|
|
|
|
#presentTooltip(event: MapMouseEvent, heading: string, details: string, kind: 'node' | 'route'): void {
|
|
const signature = `${kind}:${heading}:${details}`;
|
|
const contentChanged = signature !== this.#tooltipSignature;
|
|
if (contentChanged) {
|
|
const title = document.createElement('strong');
|
|
title.textContent = heading;
|
|
const detail = document.createElement('span');
|
|
detail.textContent = details;
|
|
this.tooltip.replaceChildren(title, detail);
|
|
this.#tooltipSignature = signature;
|
|
}
|
|
this.tooltip.dataset.kind = kind;
|
|
this.tooltip.hidden = false;
|
|
if (contentChanged || this.#tooltipSize.width <= 0 || this.#tooltipSize.height <= 0) {
|
|
this.#tooltipSize = { width: this.tooltip.offsetWidth, height: this.tooltip.offsetHeight };
|
|
}
|
|
const position = tooltipPosition(
|
|
event.point,
|
|
{ width: this.container.clientWidth, height: this.container.clientHeight },
|
|
this.#tooltipSize
|
|
);
|
|
this.tooltip.style.left = `${position.x}px`;
|
|
this.tooltip.style.top = `${position.y}px`;
|
|
}
|
|
|
|
#hideTooltip(): void {
|
|
this.tooltip.hidden = true;
|
|
delete this.tooltip.dataset.kind;
|
|
}
|
|
|
|
#adjacentRoutes(nodeID: string | null): RouteV2[] {
|
|
if (!nodeID) return [];
|
|
const routeIDs = this.#routeIDsByNode.get(nodeID);
|
|
if (!routeIDs) return [];
|
|
const routes: RouteV2[] = [];
|
|
for (const routeID of routeIDs) {
|
|
const route = this.#routesByID.get(routeID);
|
|
if (route) routes.push(route);
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
#rebuildRouteIndex(): void {
|
|
this.#routeIDsByNode.clear();
|
|
for (const route of this.#routesByID.values()) this.#indexRoute(route);
|
|
}
|
|
|
|
#indexRoute(route: RouteV2): void {
|
|
for (const nodeID of new Set([route.fromId, route.toId])) {
|
|
let routeIDs = this.#routeIDsByNode.get(nodeID);
|
|
if (!routeIDs) {
|
|
routeIDs = new Set<string>();
|
|
this.#routeIDsByNode.set(nodeID, routeIDs);
|
|
}
|
|
routeIDs.add(route.id);
|
|
}
|
|
}
|
|
|
|
#unindexRoute(route: RouteV2): void {
|
|
for (const nodeID of new Set([route.fromId, route.toId])) {
|
|
const routeIDs = this.#routeIDsByNode.get(nodeID);
|
|
routeIDs?.delete(route.id);
|
|
if (routeIDs?.size === 0) this.#routeIDsByNode.delete(nodeID);
|
|
}
|
|
}
|
|
|
|
#isSelectedRouteInspectable(routeID: string): boolean {
|
|
if (!this.#selectedNodeID) return false;
|
|
const route = this.#routesByID.get(routeID);
|
|
return Boolean(route
|
|
&& (route.fromId === this.#selectedNodeID || route.toId === this.#selectedNodeID)
|
|
&& isRecentNeighborRoute(route, Date.now(), this.#effectiveRouteAgeMS()));
|
|
}
|
|
|
|
#renderNodeInspector(force = false): void {
|
|
if (!this.#selectedNodeID) {
|
|
this.#inspectorSignature = '';
|
|
this.#closeInspector(false);
|
|
return;
|
|
}
|
|
const model = buildNodeInspectorModel(
|
|
this.#selectedNodeID,
|
|
this.#nodesByID,
|
|
this.#adjacentRoutes(this.#selectedNodeID),
|
|
Date.now(),
|
|
this.#effectiveRouteAgeMS(),
|
|
);
|
|
if (!model) {
|
|
this.#clearNodeSelection();
|
|
return;
|
|
}
|
|
const signature = [
|
|
model.node.id, model.node.label, model.node.role, model.node.observer, model.node.lat, model.node.lng,
|
|
model.node.lastSeen, this.routesVisible,
|
|
...model.neighbors.flatMap((neighbor) => [
|
|
neighbor.id, neighbor.label, neighbor.role, neighbor.lastHeard, neighbor.lastKind, neighbor.packetCount,
|
|
]),
|
|
].join('|');
|
|
if (!force && signature === this.#inspectorSignature) return;
|
|
this.#inspectorSignature = signature;
|
|
const mobile = this.#isMobileInspector();
|
|
const content = createNodeInspectorContent(document, model, {
|
|
mobile,
|
|
onClose: () => this.#clearNodeSelection(),
|
|
onSelectNeighbor: (nodeID) => this.selectNodeByID(nodeID, true),
|
|
});
|
|
if (mobile) {
|
|
this.#closePopup(false);
|
|
replaceInspector(this.inspectorSheet, content);
|
|
this.inspectorSheet.hidden = false;
|
|
return;
|
|
}
|
|
this.inspectorSheet.hidden = true;
|
|
this.inspectorSheet.replaceChildren();
|
|
const popupAnchor = this.#inspectorPopupAnchor(model.node);
|
|
if (!this.#nodeInspectorPopup || this.#nodeInspectorPopupAnchor !== popupAnchor) {
|
|
this.#closePopup(false);
|
|
this.#nodeInspectorPopupAnchor = popupAnchor;
|
|
this.#nodeInspectorPopup = new maplibregl.Popup({
|
|
anchor: popupAnchor,
|
|
closeButton: true,
|
|
closeOnClick: false,
|
|
closeOnMove: false,
|
|
focusAfterOpen: false,
|
|
maxWidth: '350px',
|
|
offset: 14,
|
|
subpixelPositioning: true,
|
|
className: 'node-inspector-popup',
|
|
});
|
|
this.#nodeInspectorPopup.on('close', () => {
|
|
if (!this.#suppressPopupClose && this.#selectedNodeID) this.#clearNodeSelection();
|
|
});
|
|
}
|
|
const popup = this.#nodeInspectorPopup
|
|
.setLngLat([model.node.lng, model.node.lat])
|
|
.setDOMContent(content);
|
|
if (!popup.isOpen()) popup.addTo(this.map);
|
|
popup.getElement().querySelector('.maplibregl-popup-close-button')?.setAttribute('aria-label', 'Close node details');
|
|
}
|
|
|
|
#closeInspector(clearSelection: boolean): void {
|
|
const restoreFocus = this.inspectorSheet.contains(document.activeElement);
|
|
this.#closePopup(clearSelection);
|
|
this.inspectorSheet.hidden = true;
|
|
this.inspectorSheet.replaceChildren();
|
|
if (restoreFocus) document.getElementById('find-button')?.focus();
|
|
}
|
|
|
|
#closePopup(clearSelection: boolean): void {
|
|
if (!this.#nodeInspectorPopup?.isOpen()) return;
|
|
this.#suppressPopupClose = !clearSelection;
|
|
this.#nodeInspectorPopup.remove();
|
|
this.#suppressPopupClose = false;
|
|
}
|
|
|
|
#isMobileInspector(): boolean {
|
|
return document.documentElement.dataset.cinematic === 'true' || this.container.clientWidth <= 620 || window.matchMedia('(pointer: coarse)').matches;
|
|
}
|
|
|
|
#inspectorPopupAnchor(node: NodeV2): 'left' | 'right' {
|
|
let neighborLongitudeDelta = 0;
|
|
for (const neighborID of this.#neighborNodeIDs) {
|
|
const neighbor = this.#nodesByID.get(neighborID);
|
|
if (neighbor) neighborLongitudeDelta += neighbor.lng - node.lng;
|
|
}
|
|
if (Math.abs(neighborLongitudeDelta) > 0.0001) {
|
|
return neighborLongitudeDelta > 0 ? 'right' : 'left';
|
|
}
|
|
return this.map.project([node.lng, node.lat]).x > this.container.clientWidth / 2 ? 'right' : 'left';
|
|
}
|
|
|
|
#centerNodeIfNeeded(node: NodeV2): void {
|
|
const point = this.map.project([node.lng, node.lat]);
|
|
const mobile = this.container.clientWidth <= 900 || window.matchMedia('(pointer: coarse)').matches;
|
|
const margin = 72;
|
|
const safeBottom = this.container.clientHeight - (mobile ? Math.min(360, this.container.clientHeight * 0.48) : margin);
|
|
const inSafeView = point.x >= margin
|
|
&& point.x <= this.container.clientWidth - (mobile ? margin : 390)
|
|
&& point.y >= margin
|
|
&& point.y <= safeBottom;
|
|
if (inSafeView && this.map.getZoom() >= DETAIL_ZOOM) return;
|
|
const camera = { center: [node.lng, node.lat] as [number, number], zoom: Math.max(DETAIL_ZOOM + 0.4, this.map.getZoom()) };
|
|
if (this.#reducedMotion) this.map.jumpTo(camera);
|
|
else this.map.easeTo({ ...camera, duration: 520, essential: false, easeId: 'cartolite-node-selection' });
|
|
}
|
|
|
|
#handleInspectorResize = (): void => {
|
|
this.#updateBuildingLayer();
|
|
if (this.#selectedNodeID) this.#renderNodeInspector(true);
|
|
};
|
|
|
|
#handleWebGLContextRestored = (): void => {
|
|
const restore = (): void => {
|
|
if (!this.#layersReady) return;
|
|
if (!this.map.isStyleLoaded()) {
|
|
this.map.once('style.load', restore);
|
|
return;
|
|
}
|
|
if (this.map.getLayer(ROUTE_WEBGL_LAYER_ID)) this.map.removeLayer(ROUTE_WEBGL_LAYER_ID);
|
|
const layer = new HistoricalRouteLayer();
|
|
layer.setOpacity(this.#appearance.routeOpacity);
|
|
layer.setLightBackground(this.#appearance.basemap !== 'dark');
|
|
layer.setRoutes([...this.#routeDetailFeatures.values()]);
|
|
layer.setVisible(this.routesVisible);
|
|
layer.setMaximumBand(routeWindowBand(this.#effectiveRouteAgeMS()));
|
|
this.#historicalRouteLayer = layer;
|
|
const before = this.map.getLayer(ROUTE_FOCUS_LAYER_IDS[0]) ? ROUTE_FOCUS_LAYER_IDS[0] : undefined;
|
|
this.map.addLayer(layer, before);
|
|
this.container.dataset.routeContextRestores = String(Number(this.container.dataset.routeContextRestores ?? 0) + 1);
|
|
};
|
|
window.requestAnimationFrame(restore);
|
|
};
|
|
|
|
#handleKeyDown = (event: KeyboardEvent): void => {
|
|
if (event.key === 'Escape' && this.#selectedNodeID) this.#clearNodeSelection();
|
|
};
|
|
}
|
|
|
|
type RouteLayerMap = Pick<maplibregl.Map, 'getLayer' | 'setLayoutProperty'>;
|
|
type RouteVisibilityMap = Pick<maplibregl.Map, 'getLayer' | 'getLayoutProperty' | 'setLayoutProperty'>;
|
|
type RouteFilterMap = Pick<maplibregl.Map, 'getLayer' | 'setFilter'>;
|
|
type FocusMap = Pick<maplibregl.Map, 'getLayer' | 'setFilter' | 'setPaintProperty' | 'setLayoutProperty'>;
|
|
type InteractiveLayerMap = Pick<maplibregl.Map, 'getLayer' | 'setFilter' | 'setLayoutProperty'>;
|
|
type ClusterVisibilityMap = Pick<maplibregl.Map, 'getLayer' | 'getLayoutProperty' | 'setLayoutProperty' | 'setLayerZoomRange'>;
|
|
type LayerFilter = Parameters<maplibregl.Map['setFilter']>[1];
|
|
type ActiveLayerFilter = Exclude<LayerFilter, null | undefined>;
|
|
|
|
export function applyRouteHitLayerVisibility(map: RouteLayerMap, visible: boolean): boolean {
|
|
if (!map.getLayer(ROUTE_HIT_LAYER_ID)) return false;
|
|
map.setLayoutProperty(ROUTE_HIT_LAYER_ID, 'visibility', visible ? 'visible' : 'none');
|
|
return true;
|
|
}
|
|
|
|
export function applyRouteVisibilityForZoom(
|
|
map: RouteVisibilityMap,
|
|
routesVisible: boolean,
|
|
_maxAge: number,
|
|
zoom: number
|
|
): boolean {
|
|
void zoom;
|
|
let changed = false;
|
|
for (const layerID of [...ROUTE_VISUAL_LAYER_IDS, ...ROUTE_FOCUS_LAYER_IDS]) {
|
|
if (!map.getLayer(layerID)) continue;
|
|
const focusLayer = (ROUTE_FOCUS_LAYER_IDS as readonly string[]).includes(layerID);
|
|
const visibility = routesVisible && focusLayer ? 'visible' : 'none';
|
|
if (map.getLayoutProperty(layerID, 'visibility') === visibility) continue;
|
|
map.setLayoutProperty(layerID, 'visibility', visibility);
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function applyNeighborRingVisibility(map: RouteLayerMap, visible: boolean): boolean {
|
|
if (!map.getLayer(NEIGHBOR_NODE_LAYER_ID)) return false;
|
|
map.setLayoutProperty(NEIGHBOR_NODE_LAYER_ID, 'visibility', visible ? 'visible' : 'none');
|
|
return true;
|
|
}
|
|
|
|
export function applyRouteSelectionFilter(map: RouteFilterMap, selectedNodeID: string | null): boolean {
|
|
if (!map.getLayer(ROUTE_HIT_LAYER_ID)) return false;
|
|
map.setFilter(ROUTE_HIT_LAYER_ID, neighborRouteFilter(selectedNodeID));
|
|
return true;
|
|
}
|
|
|
|
export function applySelectedNodeFilter(map: RouteFilterMap, selectedNodeID: string | null): boolean {
|
|
let applied = false;
|
|
for (const layerID of [SELECTED_NODE_OUTER_LAYER_ID, SELECTED_NODE_LAYER_ID]) {
|
|
if (!map.getLayer(layerID)) continue;
|
|
map.setFilter(layerID, selectedNodeFilter(selectedNodeID));
|
|
applied = true;
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
export function applyRouteHoverFilter(map: InteractiveLayerMap, routeID: string | null): boolean {
|
|
let applied = false;
|
|
for (const layerID of ROUTE_HOVER_LAYER_IDS) {
|
|
if (!map.getLayer(layerID)) continue;
|
|
map.setFilter(layerID, routeIDFilter(routeID));
|
|
map.setLayoutProperty(layerID, 'visibility', routeID ? 'visible' : 'none');
|
|
applied = true;
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
export function applyClusterHighlightFilter(map: RouteFilterMap, clusterID: number | null): boolean {
|
|
if (!map.getLayer(CLUSTER_HIGHLIGHT_LAYER_ID)) return false;
|
|
map.setFilter(CLUSTER_HIGHLIGHT_LAYER_ID, clusterIDFilter(clusterID));
|
|
return true;
|
|
}
|
|
|
|
export function applyHeatmapFocus(map: RouteFilterMap, focusIDs: readonly string[]): boolean {
|
|
let applied = false;
|
|
for (const [index, layerID] of HEATMAP_LAYER_IDS.entries()) {
|
|
if (!map.getLayer(layerID)) continue;
|
|
const kindFilter = ['==', ['get', 'kind'], PACKET_KINDS[index]!] as ActiveLayerFilter;
|
|
map.setFilter(layerID, focusIDs.length > 0
|
|
? ['all', kindFilter, nodeIDFilter(focusIDs)] as ActiveLayerFilter
|
|
: kindFilter);
|
|
applied = true;
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
export function applyClusterVisibility(map: ClusterVisibilityMap, visible: boolean): boolean {
|
|
let changed = false;
|
|
const clusterVisibility = visible ? 'visible' : 'none';
|
|
for (const layerID of CLUSTER_LAYER_IDS) {
|
|
if (!map.getLayer(layerID)) continue;
|
|
const current = map.getLayoutProperty(layerID, 'visibility') ?? 'visible';
|
|
if (current === clusterVisibility) continue;
|
|
map.setLayoutProperty(layerID, 'visibility', clusterVisibility);
|
|
changed = true;
|
|
}
|
|
const minimumNodeZoom = visible ? DETAIL_ZOOM - 0.15 : 3;
|
|
for (const layerID of UNCLUSTERED_NODE_LAYER_IDS) {
|
|
if (!map.getLayer(layerID)) continue;
|
|
map.setLayerZoomRange(layerID, minimumNodeZoom, 24);
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function applyNodeFocus(
|
|
map: FocusMap,
|
|
selectedNodeID: string | null,
|
|
focusIDs: readonly string[],
|
|
neighborIDs: readonly string[]
|
|
): boolean {
|
|
let applied = false;
|
|
if (map.getLayer(NODE_GLOW_LAYER_ID)) {
|
|
map.setFilter(NODE_GLOW_LAYER_ID, selectedNodeID ? nodeIDFilter(focusIDs) : NODE_BASE_FILTER);
|
|
map.setPaintProperty(NODE_GLOW_LAYER_ID, 'circle-opacity', nodeGlowOpacity(selectedNodeID !== null, focusIDs));
|
|
applied = true;
|
|
}
|
|
if (map.getLayer(NEIGHBOR_NODE_LAYER_ID)) {
|
|
map.setFilter(NEIGHBOR_NODE_LAYER_ID, nodeIDFilter(neighborIDs));
|
|
applied = true;
|
|
}
|
|
if (map.getLayer(NODE_LAYER_ID)) {
|
|
map.setPaintProperty(NODE_LAYER_ID, 'circle-opacity', nodeOpacity(selectedNodeID !== null, focusIDs));
|
|
applied = true;
|
|
}
|
|
if (map.getLayer(NODE_CORE_LAYER_ID)) {
|
|
map.setPaintProperty(NODE_CORE_LAYER_ID, 'circle-opacity', nodeCoreOpacity(selectedNodeID !== null, focusIDs));
|
|
applied = true;
|
|
}
|
|
if (map.getLayer(NODE_LABEL_LAYER_ID)) {
|
|
map.setFilter(NODE_LABEL_LAYER_ID, selectedNodeID ? nodeIDFilter(focusIDs) : NODE_BASE_FILTER);
|
|
map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'symbol-sort-key', labelSortKey(selectedNodeID, neighborIDs));
|
|
applied = true;
|
|
}
|
|
return applied;
|
|
}
|
|
|
|
export function neighborRouteFilter(selectedNodeID: string | null): LayerFilter {
|
|
if (!selectedNodeID) return null;
|
|
return [
|
|
'all',
|
|
['==', ['get', 'recent'], true],
|
|
['any', ['==', ['get', 'fromId'], selectedNodeID], ['==', ['get', 'toId'], selectedNodeID]]
|
|
] as LayerFilter;
|
|
}
|
|
|
|
export function selectedNodeFilter(selectedNodeID: string | null): ActiveLayerFilter {
|
|
return ['==', ['get', 'id'], selectedNodeID ?? ''] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function nodeIDFilter(nodeIDs: readonly string[]): ActiveLayerFilter {
|
|
return ['in', ['get', 'id'], ['literal', [...nodeIDs]]] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function routeIDFilter(routeID: string | null): ActiveLayerFilter {
|
|
return ['==', ['get', 'id'], routeID ?? ''] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function routeRepresentationFilter(representation: string): ActiveLayerFilter {
|
|
return ['==', ['get', 'representation'], representation] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function routeExactBandFilter(band: 0 | 1 | 2 | 3): ActiveLayerFilter {
|
|
return [
|
|
'all',
|
|
routeRepresentationFilter(ROUTE_REPRESENTATION_EXACT),
|
|
['==', ['get', 'windowBand'], band]
|
|
] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function routeTrunkFilter(representation: string): ActiveLayerFilter {
|
|
return [
|
|
'all',
|
|
routeRepresentationFilter(representation),
|
|
['==', ['get', 'local'], false]
|
|
] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function clusterIDFilter(clusterID: number | null): ActiveLayerFilter {
|
|
return ['==', ['get', 'cluster_id'], clusterID ?? -1] as ActiveLayerFilter;
|
|
}
|
|
|
|
export function labelSortKey(selectedNodeID: string | null, neighborIDs: readonly string[]): ExpressionSpecification {
|
|
if (!selectedNodeID) return ['get', 'labelPriority'];
|
|
return [
|
|
'case',
|
|
['==', ['get', 'id'], selectedNodeID],
|
|
0,
|
|
['in', ['get', 'id'], ['literal', [...neighborIDs]]],
|
|
1,
|
|
['get', 'labelPriority']
|
|
];
|
|
}
|
|
|
|
export function neighborNodeIDs(routes: readonly RouteV2[], selectedNodeID: string | null): string[] {
|
|
if (!selectedNodeID) return [];
|
|
const ids = new Set<string>();
|
|
for (const route of routes) {
|
|
if (route.fromId === selectedNodeID && route.toId !== selectedNodeID) ids.add(route.toId);
|
|
if (route.toId === selectedNodeID && route.fromId !== selectedNodeID) ids.add(route.fromId);
|
|
}
|
|
return [...ids].sort();
|
|
}
|
|
|
|
export function isRouteInspectable(
|
|
routes: readonly RouteV2[],
|
|
selectedNodeID: string | null,
|
|
routeID: string | null,
|
|
now = Date.now(),
|
|
maxAge = ROUTE_MAX_AGE_MS
|
|
): boolean {
|
|
if (!routeID) return false;
|
|
return recentNeighborRoutes(routes, selectedNodeID, now, maxAge).some((route) => route.id === routeID);
|
|
}
|
|
|
|
export function packetEndpoints(packet: PacketView): EndpointV2[] {
|
|
if (packet.mode === 'observer') return [packet.observer];
|
|
const endpoints: EndpointV2[] = [];
|
|
const seen = new Set<string>();
|
|
for (const segment of packet.segments) {
|
|
for (const endpoint of [segment.from, segment.to]) {
|
|
const key = `${endpoint.id}|${endpoint.lat}|${endpoint.lng}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
endpoints.push(endpoint);
|
|
}
|
|
}
|
|
return endpoints;
|
|
}
|
|
|
|
export function packetMatchesFollow(packet: PacketView, selectedNodeID: string | null): boolean {
|
|
if (selectedNodeID) {
|
|
if (packet.mode === 'observer') return packet.observer.id === selectedNodeID;
|
|
return packet.segments.some((segment) => (
|
|
segment.from.id === selectedNodeID || segment.to.id === selectedNodeID
|
|
));
|
|
}
|
|
return packetEndpoints(packet).some(validEndpoint);
|
|
}
|
|
|
|
export function isPointInSafeArea(
|
|
point: ViewportPoint,
|
|
viewport: ViewportSize,
|
|
safeRatio = LIVE_FOLLOW_SAFE_RATIO
|
|
): boolean {
|
|
if (viewport.width <= 0 || viewport.height <= 0) return false;
|
|
const ratio = Math.max(0, Math.min(1, safeRatio));
|
|
const marginX = viewport.width * (1 - ratio) / 2;
|
|
const marginY = viewport.height * (1 - ratio) / 2;
|
|
return point.x >= marginX
|
|
&& point.x <= viewport.width - marginX
|
|
&& point.y >= marginY
|
|
&& point.y <= viewport.height - marginY;
|
|
}
|
|
|
|
export function canMoveLiveFollow(
|
|
lastMoveAt: number,
|
|
now: number,
|
|
minimumInterval = LIVE_FOLLOW_MIN_INTERVAL_MS
|
|
): boolean {
|
|
return lastMoveAt <= 0 || now - lastMoveAt >= minimumInterval;
|
|
}
|
|
|
|
export function tooltipPosition(
|
|
anchor: ViewportPoint,
|
|
viewport: ViewportSize,
|
|
tooltip: TooltipSize,
|
|
margin = 8,
|
|
gap = 12
|
|
): ViewportPoint {
|
|
const width = Math.max(0, tooltip.width);
|
|
const height = Math.max(0, tooltip.height);
|
|
const halfWidth = width / 2;
|
|
const minimumX = margin + halfWidth;
|
|
const maximumX = Math.max(minimumX, viewport.width - margin - halfWidth);
|
|
const x = Math.max(minimumX, Math.min(maximumX, anchor.x));
|
|
const above = anchor.y - gap - height;
|
|
const below = anchor.y + gap;
|
|
const maximumY = Math.max(margin, viewport.height - margin - height);
|
|
const preferredY = above >= margin ? above : below;
|
|
return { x, y: Math.max(margin, Math.min(maximumY, preferredY)) };
|
|
}
|
|
|
|
export interface RouteVisualProperties {
|
|
width: number;
|
|
glowWidth: number;
|
|
opacity: number;
|
|
trafficLevel: number;
|
|
}
|
|
|
|
export function routeVisualProperties(
|
|
route: Pick<RouteV2, 'traffic' | 'lastHeard'>,
|
|
now: number,
|
|
trafficBaseline = 1
|
|
): RouteVisualProperties {
|
|
const score = decayedRouteTraffic(route.traffic, route.lastHeard, now);
|
|
const relative = clamp(score / Math.max(1, trafficBaseline * 3), 0, 1);
|
|
const absolute = clamp(Math.log1p(score) / Math.log(9), 0, 1);
|
|
const trafficLevel = Math.sqrt(relative * absolute);
|
|
const age = Math.max(0, now - route.lastHeard);
|
|
const recent = 1 - smoothstep(55 * 60_000, 65 * 60_000, age);
|
|
const oldProgress = clamp((age - ROUTE_BRIGHT_AGE_MS) / (ROUTE_MAX_AGE_MS - ROUTE_BRIGHT_AGE_MS), 0, 1);
|
|
return {
|
|
width: Math.min(1.72, 0.72 + 1 * trafficLevel),
|
|
glowWidth: Math.min(4.1, 2 + 2.1 * trafficLevel),
|
|
opacity: 0.38 - 0.27 * oldProgress + 0.6 * recent,
|
|
trafficLevel
|
|
};
|
|
}
|
|
|
|
export function routeColorExpression(): ExpressionSpecification {
|
|
return ['to-color', ['get', 'color']];
|
|
}
|
|
|
|
export function heatmapColorExpression(color: string): ExpressionSpecification {
|
|
return [
|
|
'interpolate', ['linear'], ['heatmap-density'],
|
|
0, colorWithAlpha(color, 0),
|
|
0.12, colorWithAlpha(color, 0.1),
|
|
0.35, colorWithAlpha(color, 0.3),
|
|
0.65, colorWithAlpha(color, 0.56),
|
|
1, colorWithAlpha(color, 0.82)
|
|
];
|
|
}
|
|
|
|
function routeWindowSuffix(maxAge: number): string {
|
|
return ROUTE_WINDOW_BUCKETS.find((bucket) => bucket.ms === maxAge)?.suffix ?? '24h';
|
|
}
|
|
|
|
export function nodeLabelPriority(node: Pick<NodeV2, 'role' | 'observer' | 'lastSeen'>, now: number): number {
|
|
const age = Math.max(0, now - node.lastSeen);
|
|
const ageRank = age < 15 * 60_000 ? 0 : age < 6 * 60 * 60_000 ? 1 : age < 24 * 60 * 60_000 ? 2 : 3;
|
|
const roleRank = node.observer
|
|
? 0
|
|
: node.role === 'repeater'
|
|
? 1
|
|
: node.role === 'room_server'
|
|
? 2
|
|
: node.role === 'companion'
|
|
? 3
|
|
: node.role === 'sensor'
|
|
? 4
|
|
: 5;
|
|
return ageRank * 10 + roleRank;
|
|
}
|
|
|
|
export function prefersReducedMotion(): boolean {
|
|
return typeof window !== 'undefined'
|
|
&& typeof window.matchMedia === 'function'
|
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
}
|
|
|
|
function focusMembership(focusIDs: readonly string[]): ExpressionSpecification {
|
|
return ['in', ['get', 'id'], ['literal', [...focusIDs]]];
|
|
}
|
|
|
|
function nodeOpacity(focused: boolean, focusIDs: readonly string[]): ExpressionSpecification {
|
|
if (!focused) return ['get', 'opacity'];
|
|
return ['case', focusMembership(focusIDs), ['get', 'opacity'], ['*', ['get', 'opacity'], 0.2]];
|
|
}
|
|
|
|
function nodeCoreOpacity(focused: boolean, focusIDs: readonly string[]): ExpressionSpecification {
|
|
if (!focused) return ['*', ['get', 'opacity'], 0.86];
|
|
return ['case', focusMembership(focusIDs), ['*', ['get', 'opacity'], 0.94], ['*', ['get', 'opacity'], 0.11]];
|
|
}
|
|
|
|
function nodeGlowOpacity(focused: boolean, focusIDs: readonly string[]): ExpressionSpecification {
|
|
const atZoom = (fade: number): ExpressionSpecification => (
|
|
focused
|
|
? ['case', focusMembership(focusIDs), ['*', ['get', 'opacity'], fade * 1.35], 0]
|
|
: ['*', ['get', 'opacity'], fade]
|
|
);
|
|
return [
|
|
'interpolate', ['linear'], ['zoom'],
|
|
DETAIL_ZOOM, atZoom(0.08),
|
|
9, atZoom(0.2),
|
|
13, atZoom(0.28)
|
|
];
|
|
}
|
|
|
|
export function mapGlyphLabel(label: string): string {
|
|
const safe = Array.from(label.normalize('NFC'), (character) => {
|
|
const codePoint = character.codePointAt(0) ?? 0;
|
|
if (codePoint >= 0x20 && codePoint <= 0x024f) return character;
|
|
if (codePoint >= 0x0370 && codePoint <= 0x052f) return character;
|
|
if (codePoint >= 0x2000 && codePoint <= 0x206f) return character;
|
|
return '';
|
|
}).join('').replace(/\s+/gu, ' ').trim();
|
|
return safe || 'MeshCore node';
|
|
}
|
|
|
|
function nodeCollection(nodes: readonly NodeV2[], now = Date.now()): FeatureCollection<Point> {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: nodes.filter(validEndpoint).map((node) => nodeFeature(node, now))
|
|
};
|
|
}
|
|
|
|
function nodeFeature(node: NodeV2, now: number): Feature<Point> {
|
|
return {
|
|
type: 'Feature',
|
|
id: node.id,
|
|
geometry: { type: 'Point', coordinates: [node.lng, node.lat] },
|
|
properties: {
|
|
id: node.id,
|
|
label: node.label,
|
|
mapLabel: mapGlyphLabel(node.label),
|
|
role: node.role,
|
|
observer: node.observer,
|
|
lastSeen: node.lastSeen,
|
|
color: roleColor(node.role),
|
|
opacity: freshness(node.lastSeen, now),
|
|
labelPriority: nodeLabelPriority(node, now)
|
|
}
|
|
};
|
|
}
|
|
|
|
export function activityHeatCollection(
|
|
routes: readonly RouteV2[],
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
now = Date.now()
|
|
): FeatureCollection<Point> {
|
|
const scores = new Map<string, number>();
|
|
const kindScores = new Map<string, Map<PacketKind, number>>();
|
|
for (const route of routes) {
|
|
const age = Math.max(0, now - route.lastHeard);
|
|
if (age > ROUTE_MAX_AGE_MS) continue;
|
|
const contribution = decayedRouteTraffic(route.traffic, route.lastHeard, now);
|
|
for (const id of new Set([route.fromId, route.toId])) {
|
|
scores.set(id, (scores.get(id) ?? 0) + contribution);
|
|
addKindHeat(kindScores, id, route.lastKind, contribution);
|
|
}
|
|
}
|
|
return heatCollection(nodes, scores, kindScores);
|
|
}
|
|
|
|
function heatCollection(
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
scores: ReadonlyMap<string, number>,
|
|
kindScores: ReadonlyMap<string, ReadonlyMap<PacketKind, number>>,
|
|
): FeatureCollection<Point> {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: [...scores.keys()]
|
|
.map((id) => heatFeature(id, nodes, scores, kindScores))
|
|
.filter((feature): feature is Feature<Point> => feature !== undefined)
|
|
.sort((left, right) => {
|
|
const leftID = String(left.id);
|
|
const rightID = String(right.id);
|
|
return (scores.get(rightID) ?? 0) - (scores.get(leftID) ?? 0) || leftID.localeCompare(rightID);
|
|
})
|
|
.slice(0, HEAT_RENDER_BUDGET)
|
|
};
|
|
}
|
|
|
|
function heatFeature(
|
|
id: string,
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
scores: ReadonlyMap<string, number>,
|
|
kindScores: ReadonlyMap<string, ReadonlyMap<PacketKind, number>>,
|
|
): Feature<Point> | undefined {
|
|
const node = nodes.get(id);
|
|
const score = scores.get(id) ?? 0;
|
|
if (!node || !validEndpoint(node) || score <= 0) return undefined;
|
|
return {
|
|
type: 'Feature',
|
|
id,
|
|
geometry: { type: 'Point', coordinates: [node.lng, node.lat] },
|
|
properties: {
|
|
id,
|
|
kind: dominantHeatKind(kindScores.get(id)),
|
|
weight: Math.round(Math.min(1, Math.log1p(score) / Math.log1p(16)) * 1_000) / 1_000
|
|
}
|
|
};
|
|
}
|
|
|
|
function addKindHeat(
|
|
scores: Map<string, Map<PacketKind, number>>,
|
|
nodeID: string,
|
|
kind: PacketKind,
|
|
contribution: number,
|
|
): void {
|
|
const byKind = scores.get(nodeID) ?? new Map<PacketKind, number>();
|
|
const next = Math.max(0, (byKind.get(kind) ?? 0) + contribution);
|
|
if (next <= 1e-9) byKind.delete(kind);
|
|
else byKind.set(kind, next);
|
|
if (byKind.size === 0) scores.delete(nodeID);
|
|
else scores.set(nodeID, byKind);
|
|
}
|
|
|
|
export function dominantHeatKind(scores: ReadonlyMap<PacketKind, number> | undefined): PacketKind {
|
|
if (!scores || scores.size === 0) return 'Other';
|
|
let selected: PacketKind = 'Other';
|
|
let maximum = Number.NEGATIVE_INFINITY;
|
|
for (const kind of PACKET_KINDS) {
|
|
const score = scores?.get(kind) ?? 0;
|
|
if (score > maximum) {
|
|
selected = kind;
|
|
maximum = score;
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
export function routeRenderCandidates(
|
|
routes: readonly RouteV2[],
|
|
now = Date.now(),
|
|
maxAge = ROUTE_MAX_AGE_MS
|
|
): RouteV2[] {
|
|
return routes
|
|
.filter((route) => Math.max(0, now - route.lastHeard) <= maxAge)
|
|
.sort((left, right) => left.lastHeard - right.lastHeard || left.id.localeCompare(right.id));
|
|
}
|
|
|
|
export function routeVisualCollection(
|
|
routes: readonly RouteV2[],
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
now = Date.now(),
|
|
maxAge = ROUTE_MAX_AGE_MS,
|
|
trafficBaseline = routeTrafficBaseline(routes, now)
|
|
): FeatureCollection<LineString> {
|
|
const exact = routeCollection(routes, nodes, now, maxAge, trafficBaseline);
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
...routeTrunkFeatures(exact.features, ROUTE_TRUNK_LEVELS[0], now),
|
|
...routeTrunkFeatures(exact.features, ROUTE_TRUNK_LEVELS[1], now),
|
|
...exact.features
|
|
]
|
|
};
|
|
}
|
|
|
|
export function routeCollection(
|
|
routes: readonly RouteV2[],
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
now = Date.now(),
|
|
maxAge = ROUTE_MAX_AGE_MS,
|
|
trafficBaseline = routeTrafficBaseline(routes, now)
|
|
): FeatureCollection<LineString> {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: routes
|
|
.map((route) => routeFeature(route, nodes, now, trafficBaseline, maxAge))
|
|
.filter((feature): feature is Feature<LineString> => feature !== undefined)
|
|
};
|
|
}
|
|
|
|
function routeFeature(
|
|
route: RouteV2 | undefined,
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
now: number,
|
|
trafficBaseline: number,
|
|
maxAge: number
|
|
): Feature<LineString> | undefined {
|
|
if (!route || Math.max(0, now - route.lastHeard) > maxAge) return undefined;
|
|
const from = nodes.get(route.fromId);
|
|
const to = nodes.get(route.toId);
|
|
if (!from || !to || !validEndpoint(from) || !validEndpoint(to)) return undefined;
|
|
const visual = routeVisualProperties(route, now, trafficBaseline);
|
|
return {
|
|
type: 'Feature',
|
|
id: route.id,
|
|
geometry: { type: 'LineString', coordinates: [[from.lng, from.lat], [from.lng + longitudeDelta(from.lng, to.lng), to.lat]] },
|
|
properties: {
|
|
id: route.id,
|
|
fromId: route.fromId,
|
|
toId: route.toId,
|
|
recent: isRecentNeighborRoute(route, now),
|
|
representation: ROUTE_REPRESENTATION_EXACT,
|
|
windowBand: routeWindowBand(Math.max(0, now - route.lastHeard)),
|
|
routeCount: 1,
|
|
color: payloadColor(route.lastKind),
|
|
lastKind: route.lastKind,
|
|
lastHeard: route.lastHeard,
|
|
width: visual.width,
|
|
glowWidth: visual.glowWidth,
|
|
opacity: visual.opacity
|
|
}
|
|
};
|
|
}
|
|
|
|
interface RouteTrunkWindowAccumulator {
|
|
count: number;
|
|
newestAt: number;
|
|
color: string;
|
|
opacity: number;
|
|
}
|
|
|
|
interface RouteTrunkAccumulator {
|
|
key: string;
|
|
count: number;
|
|
fromCell: string;
|
|
toCell: string;
|
|
windows: Record<(typeof ROUTE_WINDOW_BUCKETS)[number]['key'], RouteTrunkWindowAccumulator>;
|
|
}
|
|
|
|
interface RouteSourceCollections {
|
|
individual: FeatureCollection<LineString>;
|
|
maxSliceMS: number;
|
|
}
|
|
|
|
async function buildRouteSourceCollections(
|
|
routes: readonly RouteV2[],
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
now: number,
|
|
maxAge: number,
|
|
trafficBaseline: number,
|
|
previous: ReadonlyMap<string, Feature<LineString>>,
|
|
dirtyRouteIDs: ReadonlySet<string>,
|
|
rebuildAll: boolean,
|
|
active: () => boolean
|
|
): Promise<RouteSourceCollections | undefined> {
|
|
const exact: Feature<LineString>[] = [];
|
|
let maxSliceMS = 0;
|
|
|
|
for (let offset = 0; offset < routes.length; offset += ROUTE_SOURCE_BUILD_BATCH) {
|
|
await nextAnimationFrame();
|
|
if (!active()) return undefined;
|
|
const sliceStarted = performance.now();
|
|
const end = Math.min(routes.length, offset + ROUTE_SOURCE_BUILD_BATCH);
|
|
for (let index = offset; index < end; index += 1) {
|
|
const route = routes[index];
|
|
if (!route || Math.max(0, now - route.lastHeard) > maxAge) continue;
|
|
const cached = !rebuildAll && !dirtyRouteIDs.has(route.id) ? previous.get(route.id) : undefined;
|
|
const feature = cached ?? routeFeature(route, nodes, now, trafficBaseline, maxAge);
|
|
if (!feature) continue;
|
|
exact.push(feature);
|
|
}
|
|
maxSliceMS = Math.max(maxSliceMS, performance.now() - sliceStarted);
|
|
}
|
|
|
|
if (!active()) return undefined;
|
|
return {
|
|
individual: { type: 'FeatureCollection', features: exact },
|
|
maxSliceMS
|
|
};
|
|
}
|
|
|
|
function nextAnimationFrame(): Promise<void> {
|
|
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
|
|
}
|
|
|
|
function routeTrunkFeatures(
|
|
routes: readonly Feature<LineString>[],
|
|
level: typeof ROUTE_TRUNK_LEVELS[number],
|
|
now: number
|
|
): Feature<LineString>[] {
|
|
const trunks = new Map<string, RouteTrunkAccumulator>();
|
|
for (const route of routes) addRouteToTrunks(trunks, route, level, now);
|
|
return routeTrunksFromMap(trunks, level);
|
|
}
|
|
|
|
function addRouteToTrunks(
|
|
trunks: Map<string, RouteTrunkAccumulator>,
|
|
route: Feature<LineString>,
|
|
level: typeof ROUTE_TRUNK_LEVELS[number],
|
|
now: number
|
|
): void {
|
|
const first = route.geometry.coordinates[0];
|
|
const last = route.geometry.coordinates[route.geometry.coordinates.length - 1];
|
|
if (!first || !last) return;
|
|
let from = [first[0]!, first[1]!] as [number, number];
|
|
let to = [last[0]!, last[1]!] as [number, number];
|
|
let fromCell = routeCellKey(from, level.zoom, level.gridPixels);
|
|
let toCell = routeCellKey(to, level.zoom, level.gridPixels);
|
|
if (fromCell > toCell || (fromCell === toCell && compareCoordinates(from, to) > 0)) {
|
|
[from, to] = [to, from];
|
|
[fromCell, toCell] = [toCell, fromCell];
|
|
}
|
|
const key = `${fromCell}|${toCell}`;
|
|
const properties = route.properties ?? {};
|
|
const lastHeard = Number(properties.lastHeard ?? 0);
|
|
const opacity = Number(properties.opacity ?? 0);
|
|
let trunk = trunks.get(key);
|
|
if (!trunk) {
|
|
trunk = {
|
|
key,
|
|
count: 0,
|
|
fromCell,
|
|
toCell,
|
|
windows: Object.fromEntries(ROUTE_WINDOW_BUCKETS.map((bucket) => [bucket.key, {
|
|
count: 0,
|
|
newestAt: 0,
|
|
color: '#73d9cf',
|
|
opacity: 0
|
|
}])) as RouteTrunkAccumulator['windows']
|
|
};
|
|
trunks.set(key, trunk);
|
|
}
|
|
trunk.count += 1;
|
|
const age = Math.max(0, now - lastHeard);
|
|
for (const bucket of ROUTE_WINDOW_BUCKETS) {
|
|
if (age > bucket.ms) continue;
|
|
const metrics = trunk.windows[bucket.key];
|
|
metrics.count += 1;
|
|
metrics.opacity = Math.max(metrics.opacity, opacity);
|
|
if (lastHeard >= metrics.newestAt) {
|
|
metrics.newestAt = lastHeard;
|
|
metrics.color = String(properties.color ?? metrics.color);
|
|
}
|
|
}
|
|
}
|
|
|
|
function routeTrunksFromMap(
|
|
trunks: ReadonlyMap<string, RouteTrunkAccumulator>,
|
|
level: typeof ROUTE_TRUNK_LEVELS[number]
|
|
): Feature<LineString>[] {
|
|
return [...trunks.values()]
|
|
.sort((left, right) => left.key.localeCompare(right.key))
|
|
.map((trunk): Feature<LineString> => {
|
|
const anchors = routeTrunkAnchors(trunk.fromCell, trunk.toCell, level.zoom, level.gridPixels);
|
|
const properties: Record<string, string | number | boolean> = {
|
|
representation: level.representation,
|
|
local: trunk.fromCell === trunk.toCell
|
|
};
|
|
for (const bucket of ROUTE_WINDOW_BUCKETS) {
|
|
const metrics = trunk.windows[bucket.key];
|
|
const density = Math.log2(metrics.count + 1);
|
|
properties[`routeCount${bucket.suffix}`] = metrics.count;
|
|
properties[`color${bucket.suffix}`] = metrics.color;
|
|
properties[`lastHeard${bucket.suffix}`] = metrics.newestAt;
|
|
properties[`width${bucket.suffix}`] = Math.min(3.8, 0.68 + density * 0.46);
|
|
properties[`glowWidth${bucket.suffix}`] = Math.min(7.2, 1.5 + density * 0.74);
|
|
properties[`opacity${bucket.suffix}`] = metrics.count === 0
|
|
? 0
|
|
: Math.min(1, metrics.opacity * (0.66 + Math.min(0.34, density / 8)));
|
|
}
|
|
properties.routeCount = properties.routeCount24h ?? 0;
|
|
properties.color = properties.color24h ?? '#73d9cf';
|
|
properties.lastHeard = properties.lastHeard24h ?? 0;
|
|
properties.width = properties.width24h ?? 0;
|
|
properties.glowWidth = properties.glowWidth24h ?? 0;
|
|
properties.opacity = properties.opacity24h ?? 0;
|
|
return {
|
|
type: 'Feature',
|
|
id: `trunk:${level.representation}:${trunk.key}`,
|
|
geometry: {
|
|
type: 'LineString',
|
|
coordinates: anchors
|
|
},
|
|
properties
|
|
};
|
|
});
|
|
}
|
|
|
|
function routeCellKey(coordinate: readonly [number, number], zoom: number, gridPixels: number): string {
|
|
const longitude = Math.max(-180, Math.min(180, coordinate[0]));
|
|
const latitude = Math.max(-85.0511287798, Math.min(85.0511287798, coordinate[1]));
|
|
const radians = latitude * Math.PI / 180;
|
|
const x = (longitude + 180) / 360;
|
|
const y = (1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2;
|
|
const cellSize = gridPixels / (512 * (2 ** zoom));
|
|
return `${Math.floor(x / cellSize)}:${Math.floor(y / cellSize)}`;
|
|
}
|
|
|
|
function routeTrunkAnchors(
|
|
fromCell: string,
|
|
toCell: string,
|
|
zoom: number,
|
|
gridPixels: number
|
|
): [number, number][] {
|
|
const cellSize = gridPixels / (512 * (2 ** zoom));
|
|
const center = (cell: string): [number, number] => {
|
|
const [column = 0, row = 0] = cell.split(':').map(Number);
|
|
return [(column + 0.5) * cellSize, (row + 0.5) * cellSize];
|
|
};
|
|
const from = center(fromCell);
|
|
const to = center(toCell);
|
|
if (fromCell === toCell) {
|
|
const points = 10;
|
|
const radius = cellSize * 0.09;
|
|
return Array.from({ length: points + 1 }, (_, index) => {
|
|
const angle = Math.PI * 2 * index / points;
|
|
return normalizedMercatorToLngLat(
|
|
from[0] + Math.cos(angle) * radius,
|
|
from[1] + Math.sin(angle) * radius
|
|
);
|
|
});
|
|
}
|
|
return [normalizedMercatorToLngLat(from[0], from[1]), normalizedMercatorToLngLat(to[0], to[1])];
|
|
}
|
|
|
|
function normalizedMercatorToLngLat(x: number, y: number): [number, number] {
|
|
const boundedY = Math.max(0, Math.min(1, y));
|
|
const longitude = x * 360 - 180;
|
|
const latitude = Math.atan(Math.sinh(Math.PI * (1 - 2 * boundedY))) * 180 / Math.PI;
|
|
return [longitude, latitude];
|
|
}
|
|
|
|
function compareCoordinates(left: readonly [number, number], right: readonly [number, number]): number {
|
|
return left[0] - right[0] || left[1] - right[1];
|
|
}
|
|
|
|
export function routeTrunkFeaturesForWindow(
|
|
features: readonly Feature<LineString>[],
|
|
maxAge: number
|
|
): Feature<LineString>[] {
|
|
const suffix = routeWindowSuffix(maxAge);
|
|
return features.map((feature) => {
|
|
const properties = feature.properties ?? {};
|
|
return {
|
|
...feature,
|
|
properties: {
|
|
...properties,
|
|
routeCount: properties[`routeCount${suffix}`] ?? 0,
|
|
color: properties[`color${suffix}`] ?? '#73d9cf',
|
|
lastHeard: properties[`lastHeard${suffix}`] ?? 0,
|
|
width: properties[`width${suffix}`] ?? 0,
|
|
glowWidth: properties[`glowWidth${suffix}`] ?? 0,
|
|
opacity: properties[`opacity${suffix}`] ?? 0
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
function countEligibleRoutes(
|
|
routes: Iterable<RouteV2>,
|
|
nodes: ReadonlyMap<string, NodeV2>,
|
|
now: number,
|
|
maxAge: number
|
|
): number {
|
|
let count = 0;
|
|
for (const route of routes) {
|
|
if (Math.max(0, now - route.lastHeard) > maxAge) continue;
|
|
const from = nodes.get(route.fromId);
|
|
const to = nodes.get(route.toId);
|
|
if (from && to && validEndpoint(from) && validEndpoint(to)) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function routeTrafficBaseline(routes: readonly RouteV2[], now: number): number {
|
|
if (routes.length === 0) return 0;
|
|
let logTotal = 0;
|
|
for (const route of routes) logTotal += Math.log1p(decayedRouteTraffic(route.traffic, route.lastHeard, now));
|
|
return Math.expm1(logTotal / routes.length);
|
|
}
|
|
|
|
function validEndpoint(endpoint: EndpointV2): boolean {
|
|
return Number.isFinite(endpoint.lat) && Number.isFinite(endpoint.lng) && Math.abs(endpoint.lat) <= 90 && Math.abs(endpoint.lng) <= 180;
|
|
}
|
|
|
|
function roleColor(role: NodeV2['role']): string {
|
|
if (role === 'repeater') return '#45c27f';
|
|
if (role === 'companion') return '#53a7e8';
|
|
if (role === 'room_server') return '#ab76dc';
|
|
if (role === 'sensor') return '#a2ad57';
|
|
return '#8794a6';
|
|
}
|
|
|
|
|
|
function freshness(timestamp: number, now: number): number {
|
|
const age = Math.max(0, now - timestamp);
|
|
if (age < 15 * 60_000) return 1;
|
|
if (age < 6 * 60 * 60_000) return 0.68;
|
|
if (age < 24 * 60 * 60_000) return 0.4;
|
|
return 0.2;
|
|
}
|
|
|
|
export function effectiveRouteWindowMS(window: RouteWindow, zoom: number): number {
|
|
if (window === '15m') return 15 * 60_000;
|
|
if (window === '1h') return 60 * 60_000;
|
|
if (window === '6h') return 6 * 60 * 60_000;
|
|
if (window === '24h') return ROUTE_MAX_AGE_MS;
|
|
if (zoom < 5.5) return 15 * 60_000;
|
|
if (zoom < 7.5) return 60 * 60_000;
|
|
if (zoom < 9.5) return 6 * 60 * 60_000;
|
|
return ROUTE_MAX_AGE_MS;
|
|
}
|
|
|
|
export function routeWindowBand(age: number): 0 | 1 | 2 | 3 {
|
|
const bounded = Math.max(0, age);
|
|
if (bounded <= ROUTE_WINDOW_BUCKETS[0].ms) return 0;
|
|
if (bounded <= ROUTE_WINDOW_BUCKETS[1].ms) return 1;
|
|
if (bounded <= ROUTE_WINDOW_BUCKETS[2].ms) return 2;
|
|
return 3;
|
|
}
|
|
|
|
export function routeRepresentationForZoom(_zoom: number): RouteRepresentation {
|
|
return 'individual-routes';
|
|
}
|
|
|
|
export function routeWindowLabel(window: RouteWindow, zoom: number): string {
|
|
const age = effectiveRouteWindowMS(window, zoom);
|
|
const label = age === 15 * 60_000 ? '15m' : age === 60 * 60_000 ? '1h' : age === 6 * 60 * 60_000 ? '6h' : '24h';
|
|
return window === 'auto' ? `Auto · ${label}` : label;
|
|
}
|
|
|
|
function clamp(value: number, minimum: number, maximum: number): number {
|
|
return Math.max(minimum, Math.min(maximum, value));
|
|
}
|
|
|
|
function colorWithAlpha(color: string, alpha: number): string {
|
|
return alphaColor(/^#[0-9a-f]{6}$/i.test(color) ? color : '#9caebd', alpha);
|
|
}
|
|
|
|
function smoothstep(edge0: number, edge1: number, value: number): number {
|
|
const progress = clamp((value - edge0) / (edge1 - edge0), 0, 1);
|
|
return progress * progress * (3 - 2 * progress);
|
|
}
|