Files
gods-eye-view/src/layers/bikeshare/state.js
T
Cliff Flood 001043f74e fix(bikeshare): let one dead city feed fail without failing the layer
The layer recorded failure in a single `_error` string. Every city feed is
fetched independently, so one retired operator set that flag for the whole
layer — and the only code that cleared it ran on a successful *activation*,
which `runProximityCheck` skips for a city that already has rendered points.
A dead feed that re-entered camera range therefore set the flag on every
camera move and nothing ever cleared it: a permanent LOAD FAILED over a map
drawing every other city's stations.

Failure is now tracked per city in `_failedCityIds`, and `getStats()` reports
it only when the failures left nothing to draw. A city that recovers removes
its own entry rather than waiting for a neighbour to succeed.

This was reachable only because Buffalo sits 95 km from Toronto, inside the
100 km activation radius — the one dead city in the registry with a healthy
neighbour in range. The other four failed alone, where LOAD FAILED with an
empty map reads as correct, which is why the latch went unnoticed.

Probing all 33 registry systems found five dead. Three had moved and are
repointed: Reddy Bikeshare (Buffalo) to Social Bicycles, Tugo (Tucson) and
Trinity Metro (Fort Worth) to PBSC, all verified serving stations. Two are
dropped because the systems wound down rather than moved — Broward B-cycle
404s at every documented endpoint, and CoGo's replacement Lyft feed publishes
zero stations with status last reported in March 2025. `gbfs.cogobikeshare.com`
no longer resolves, so its allowlist entry goes too.

All 31 remaining systems were verified live, and every registry URL was
checked against the proxy host and path allowlists.
2026-10-13 18:55:20 -04:00

118 lines
3.7 KiB
JavaScript

export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
services.overlays;
const layerState = {};
layerState.DEFAULT_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
layerState._overlayHost = layerState.DEFAULT_OVERLAY_HOST;
// ---------------------------------------------------------------------------
// Module-level mutable state
// ---------------------------------------------------------------------------
/** @type {Cesium.Viewer|null} Active Cesium viewer instance. */
layerState._viewer = null;
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
layerState._pointCollection = null;
/** Whether the bikeshare layer is currently enabled. */
layerState._enabled = false;
/** Timer handle for camera-move debounce. */
layerState._cameraDebounceTimer = null;
/** Whether the camera.changed listener is currently attached. */
layerState._cameraChangedAttached = false;
/** Hysteresis flag for altitude-based activation. */
layerState._altitudeGateEnabled = false;
/** Monotonic generation counter; incremented on each proximity check to cancel stale work. */
layerState._proximityGeneration = 0;
/** @type {Set<string>} City ids currently considered in-range. */
layerState._activeCityIds = new Set();
/** @type {Map<string, { stationKeys: Set<string> }>} Per-city runtime tracking of rendered station keys. */
layerState._cityRuntime = new Map();
/** @type {Map<string, Map<string, Object>>} Cached station information per city (cityId -> stationId -> StationInfo). */
layerState._stationInfoCache = new Map();
/** @type {Map<string, { timestamp: number, statusMap: Map<string, Object> }>} Cached station status per city. */
layerState._statusCache = new Map();
/** @type {Map<string, { promise: Promise, controller: AbortController, generation: number }>} In-flight station info requests. */
layerState._inFlightInfo = new Map();
/** @type {Map<string, { promise: Promise, controller: AbortController, generation: number }>} In-flight station status requests. */
layerState._inFlightStatus = new Map();
/** @type {Map<string, Object>} Render records keyed by "cityId:stationId". */
layerState._stationRenderMap = new Map();
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */
layerState._clickHandler = null;
/** @type {string|null} Key of the currently selected station, or null. */
layerState._selectedKey = null;
/** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */
layerState._selectedEntity = null;
/** Total number of currently rendered station points. */
layerState._count = 0;
/** Timestamp (ms) of the last successful status update. */
layerState._lastUpdate = null;
/** Whether any GBFS fetch is currently in progress. */
layerState._loading = false;
/** Reference count of concurrent loading operations. */
layerState._loadingOps = 0;
/**
* @type {Set<string>} City ids whose most recent fetch failed.
*
* Per city rather than one layer-wide flag: feeds are fetched independently,
* so a retired operator must not mark a layer that other cities are still
* populating as failed. The layer reports failure only when these cost it
* everything it had to draw — see `getStats()` in controls.js.
*/
layerState._failedCityIds = new Set();
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
layerState._limitWarned = false;
return layerState;
}