mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Supersedes #218 by @spcpza, ported onto the current component layout with fixes from review. Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied. Contributor: @spcpza. His original commit is the base of this branch, and his own re-port onto main (2026-09-15, `5b69011`) was consulted while finishing the height work. That port reads every vehicle's surface from the app's shared ground-floor system (`cachedGroundFloor` / `warmGroundFloor` / `GROUND_FLOOR_LIFT_M`) rather than sampling the scene per layer, keeps a vehicle hidden until its cell answers, warms a bounded batch per poll on a timer instead of per frame, and renders at the ellipsoid above the altitude gate. That is the sounder primitive — one floor system for the app, with the rooftop-versus-street question already settled against a datum-correct DEM prior — and this branch's height work was rebuilt on it, replacing a bespoke cell cache, ground prior and rooftop rule written here. Credit for that direction is his. Future work (not in this PR), both written up in docs/CURRENT-STATE.md: - Readable route names. The realtime feeds carry only `route_id`; `route_short_name` / `route_long_name` live in each operator's static GTFS `routes.txt`, and headsigns in `trips.txt`. Doing it properly wants a server-side metadata loader beside the transit proxy with fixed registered static sources, bounded indexes built once and persisted, a slow conditional refresh, and a versioned route dictionary the browser caches. Shipped here: ids are never rewritten, and a cut one carries an ellipsis instead of reading as a typo. - Below-ground rendering. Subway vehicles are drawn on the surface directly above themselves, because the feeds carry no depth and a train nobody can see is not worth drawing; the card says so plainly. Real treatment wants OSM tunnel geometry per city and a ghosted sprite tethered to the surface. The owner recorded it and left it. Round four and after (2026-09-15). The owner tested round three under FLIR and NVG, and then asked for the design to be taken further: elegant in performance, beautiful on camera, trails with a cache for public transport, Cesium-native, and a core other moving layers can adopt. Codex Astra reviewed the plan (it confirmed the speed fault: a glide started from the drawn position at receipt time but spanned the gap between report times, so with MBTA reporting every ~10 s against a 15 s poll every second glide ran at 1.5x; it rejected a queued-segment patch), then wrote the design, then implemented it in commit-sized passes with adversarial review between them and real-browser harness runs by the integrator. What this PR now contains beyond the contributor's original port: - Delayed playback in a contact-agnostic core (`src/data/contactPlayback.js`): each vehicle keeps a typed, bounded fix history and a display clock that runs at one second per second through it, a feed-derived lag behind real time, holding at its newest fix rather than extrapolating and never sprinting to catch up. Marker, course, motion word, card and trail all read the same displayed sample. The clock has an explicit time-offset and seek API so a rewind is a later feature, not a rewrite; flights and vessels are untouched. - A selected-vehicle trail (`src/data/contactTrailRenderer.js`, `src/layers/transit/trails.js`): the retained history drawn with batched Cesium primitives, clipped at display time so the head is exactly the sprite, faded by age, in the mode colour (white under sensor presets), on the same surface path as the marker. - Bounded proxy-side history (`server/providers/transitHistory.js`): fifteen minutes and at most 128 fixes per vehicle with hard byte ceilings, MBTA only for now (opt-in per feed), served by a small endpoint that never fetches upstream, so a selected trail survives a browser reload while the server keeps running. - Scene work bounded to what is visible and moving, an occluder, cached DETECT candidates, holds on the render loop only while something visible moves. - Sensor presets: white-hot haloed sprites under NVG/FLIR/noir (black-hot inverts), saturated mode colours under CRT, per-mode bracket tiers in every theme, the selected card keeping its mode colour. - A discriminating browser harness (`scripts/qa-transit.mjs`) that samples postRender frames, reads the real card anchor, and measures fleet budgets at 800 and 3,000 vehicles. Not built, recorded as follow-ups: curated route display names (lowest priority per the owner), fleet-wide tails, route snapping, a rewind UI, adoption of the playback core by flights and vessels. Verified at merge (2026-09-15 morning): unit suite 3,896 passing, build clean, format and boundary gates clean, tracking regression 109/109, browser harness keyless 200 pass / 0 fail (13 sensor checks unexercised because the live view offered fewer than six isolated sprites; every judged sprite passed), keyed on Google 3D tiles 198 pass with the per-frame allocation gate as the only failure. Follow-ups recorded: the keyed build's playback sampler still allocates per frame (CPU and frame time stay within budget; the 3,000-vehicle metro view sits at a 27.6 ms frame interval against a 25 ms budget), the shared detection overlay's per-paint allocation, friendlier route names, fleet-wide tails, a rewind UI, and adopting the playback core for vessels and flights. Co-authored-by: spcpza <[email protected]>
405 lines
16 KiB
JavaScript
405 lines
16 KiB
JavaScript
/**
|
||
* @module transitFeeds
|
||
* @description Registry of keyless, openly licensed GTFS-Realtime
|
||
* VehiclePositions feeds the Transit layer can show.
|
||
*
|
||
* Every entry here is a URL the SERVER fetches — the browser only ever asks
|
||
* `/api/transit/vehicles/<id>` for a registered id (see SECURITY.md: proxies
|
||
* never fetch client-supplied URLs). Adding a feed means adding a row here,
|
||
* a DATA_SOURCES.md row with its license, and a credit in dataCredits.js.
|
||
*
|
||
* Admission rules for a feed:
|
||
* - No key, token, or registration required (identify-yourself headers are
|
||
* fine — Entur asks for `ET-Client-Name`, OVapi for a User-Agent).
|
||
* - An open license that permits display with attribution.
|
||
* - Real coordinates in VehiclePosition.position — NYCT subway, for example,
|
||
* publishes stop-relative positions only and is deliberately absent.
|
||
*
|
||
* `defaultEnabled` is separate from being registered. A feed ships switched ON
|
||
* only when its operator's own published terms were READ and plainly cover this
|
||
* use. A registered feed with `defaultEnabled: false` is never polled and never
|
||
* offered to the browser; it is here so the decision is visible and reversible
|
||
* in one line. `terms` records the passage that decision rests on, so the next
|
||
* reader does not have to take the licence string on faith.
|
||
*
|
||
* Pure data + pure helpers: imported by the browser layer, the Vite proxy, and
|
||
* node:test. No Cesium, no Node built-ins.
|
||
*/
|
||
|
||
/** Transit modes the layer colors. `routeMode` hints refine a feed's default. */
|
||
export const TRANSIT_MODES = Object.freeze([
|
||
'bus',
|
||
'tram',
|
||
'subway',
|
||
'rail',
|
||
'ferry',
|
||
'unknown',
|
||
]);
|
||
|
||
/** Per-mode icon used in labels and the selection card. */
|
||
export const TRANSIT_MODE_ICON = Object.freeze({
|
||
bus: '🚌',
|
||
tram: '🚊',
|
||
subway: '🚇',
|
||
rail: '🚆',
|
||
ferry: '⛴️',
|
||
unknown: '🚏',
|
||
});
|
||
|
||
/**
|
||
* MBTA route ids are human-readable and mode-typed: rapid-transit lines carry
|
||
* colour names, commuter rail is prefixed `CR-`, ferries `Boat-`, buses are
|
||
* numeric (or `SL`/`CT` express families).
|
||
* @param {string|null} routeId
|
||
* @returns {string}
|
||
*/
|
||
function mbtaRouteMode(routeId) {
|
||
if (!routeId) return 'unknown';
|
||
if (/^(Red|Orange|Blue)\b/.test(routeId)) return 'subway';
|
||
if (/^(Green|Mattapan)/.test(routeId)) return 'tram';
|
||
if (/^CR-/.test(routeId)) return 'rail';
|
||
if (/^Boat-/.test(routeId)) return 'ferry';
|
||
if (/^Shuttle/i.test(routeId)) return 'bus';
|
||
return 'bus';
|
||
}
|
||
|
||
/**
|
||
* Entur (Norway) route ids are `<codespace>:Line:<local-id>`; the codespace
|
||
* tells the operator, not the mode, but a few are single-mode operators.
|
||
* @param {string|null} routeId
|
||
* @returns {string}
|
||
*/
|
||
function enturRouteMode(routeId) {
|
||
if (!routeId) return 'unknown';
|
||
const codespace = routeId.split(':')[0];
|
||
if (
|
||
codespace === 'VYG' ||
|
||
codespace === 'GJB' ||
|
||
codespace === 'SJN' ||
|
||
codespace === 'FLT' ||
|
||
codespace === 'GOA' ||
|
||
codespace === 'NSB' ||
|
||
codespace === 'VYT'
|
||
)
|
||
return 'rail';
|
||
if (codespace === 'FLB') return 'rail';
|
||
return 'bus';
|
||
}
|
||
|
||
/**
|
||
* HSL route ids start with a four-digit code whose first digit is the mode
|
||
* family in HSL's numbering: 1xxx/2xxx… are trams (1001–1010) for 4-digit ids
|
||
* beginning with `10`, metro routes are `31M…`, ferries `1019`.
|
||
* @param {string|null} routeId
|
||
* @returns {string}
|
||
*/
|
||
function hslRouteMode(routeId) {
|
||
if (!routeId) return 'unknown';
|
||
if (/^31M/.test(routeId)) return 'subway';
|
||
if (/^10(0[1-9]|10|15)/.test(routeId)) return 'tram';
|
||
if (/^1019/.test(routeId)) return 'ferry';
|
||
if (/^300[0-9A-Z]/.test(routeId)) return 'rail';
|
||
return 'bus';
|
||
}
|
||
|
||
/**
|
||
* Metro Transit (Minneapolis–St Paul): light rail is the Blue/Green line
|
||
* (route ids 901/902), Northstar commuter rail is 888.
|
||
* @param {string|null} routeId
|
||
* @returns {string}
|
||
*/
|
||
function metroTransitRouteMode(routeId) {
|
||
if (!routeId) return 'unknown';
|
||
if (routeId === '901' || routeId === '902') return 'tram';
|
||
if (routeId === '888') return 'rail';
|
||
return 'bus';
|
||
}
|
||
|
||
/**
|
||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||
* @type {ReadonlyArray<Readonly<{
|
||
* id: string, name: string, operator: string, region: string,
|
||
* center: {lat: number, lon: number}, loadRadiusKm: number,
|
||
* url: string, headers?: Record<string, string>,
|
||
* license: string, licenseUrl: string, attribution: string,
|
||
* defaultMode: string, routeMode?: (routeId: string|null) => string,
|
||
* }>>}
|
||
*/
|
||
export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||
Object.freeze({
|
||
id: 'mbta',
|
||
historyRetention: true,
|
||
name: 'MBTA',
|
||
operator: 'Massachusetts Bay Transportation Authority',
|
||
region: 'Boston, MA',
|
||
center: Object.freeze({ lat: 42.3601, lon: -71.0589 }),
|
||
loadRadiusKm: 70,
|
||
url: 'https://cdn.mbta.com/realtime/VehiclePositions.pb',
|
||
license: 'MassDOT Developers License Agreement',
|
||
licenseUrl:
|
||
'https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf',
|
||
attribution: 'MBTA / MassDOT',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote:
|
||
'MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data.',
|
||
note: 'Credit is text only: the agreement forbids using MBTA/MassDOT logos or trademarks with the data.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
routeMode: mbtaRouteMode,
|
||
}),
|
||
Object.freeze({
|
||
id: 'capmetro-austin',
|
||
name: 'CapMetro',
|
||
operator: 'Capital Metropolitan Transportation Authority',
|
||
region: 'Austin, TX',
|
||
center: Object.freeze({ lat: 30.2672, lon: -97.7431 }),
|
||
loadRadiusKm: 60,
|
||
url: 'https://data.texas.gov/download/eiei-9rpf/application%2Foctet-stream',
|
||
license: 'CapMetro Developer Tools license',
|
||
licenseUrl: 'https://www.capmetro.org/developertools',
|
||
attribution:
|
||
'Capital Metropolitan Transportation Authority — data.texas.gov',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote:
|
||
'Capital Metropolitan Transportation Authority (CMTA) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data.',
|
||
note: 'CMTA trademarks may not be used in association with the data, so the credit is the operator name as text.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
}),
|
||
Object.freeze({
|
||
id: 'metrotransit-msp',
|
||
name: 'Metro Transit',
|
||
operator: 'Metro Transit (Metropolitan Council)',
|
||
region: 'Minneapolis–St Paul, MN',
|
||
center: Object.freeze({ lat: 44.9778, lon: -93.265 }),
|
||
loadRadiusKm: 70,
|
||
url: 'https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb',
|
||
license: 'Public Metro Transit vehicle position data',
|
||
licenseUrl: 'https://svc.metrotransit.org/',
|
||
attribution: 'Metro Transit — Metropolitan Council',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote:
|
||
"Metro Transit's GTFS-realtime feeds are refreshed every 5 seconds.",
|
||
note: 'The realtime feed is published from a developer page carrying operational guidance and no licence text of its own. The public-domain statement below belongs to the Metropolitan Council metadata for the COMPANION SCHEDULE dataset, not to this feed: "None. This dataset is public domain under the Minnesota Government Data Practices Act". Credited as a courtesy, the way the other public agency feeds are.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
routeMode: metroTransitRouteMode,
|
||
}),
|
||
Object.freeze({
|
||
id: 'hsl-helsinki',
|
||
name: 'HSL',
|
||
operator: 'Helsinki Region Transport (HSL)',
|
||
region: 'Helsinki, Finland',
|
||
center: Object.freeze({ lat: 60.1699, lon: 24.9384 }),
|
||
loadRadiusKm: 70,
|
||
url: 'https://realtime.hsl.fi/realtime/vehicle-positions/v2/hsl',
|
||
license: 'CC BY 4.0',
|
||
licenseUrl: 'https://www.hsl.fi/en/hsl/open-data',
|
||
attribution: 'HSL (Helsinki Region Transport)',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote: null,
|
||
note: 'The open-data page refuses automated readers (HTTP 403, bot protection), so this entry rests on the owner opening it and accepting it rather than on a passage quoted here. HSL publishes its open data under CC BY 4.0 and asks to be credited as the source; the feed needs no key.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
routeMode: hslRouteMode,
|
||
}),
|
||
Object.freeze({
|
||
id: 'ovapi-nl',
|
||
name: 'OVapi',
|
||
operator: 'Stichting OpenGeo (NDOV data)',
|
||
region: 'Netherlands',
|
||
center: Object.freeze({ lat: 52.2, lon: 5.3 }),
|
||
loadRadiusKm: 220,
|
||
url: 'https://gtfs.ovapi.nl/nl/vehiclePositions.pb',
|
||
license: 'Free to use per the OVapi README (best effort, no SLA)',
|
||
licenseUrl: 'https://gtfs.ovapi.nl/README',
|
||
attribution:
|
||
'OVapi / Stichting OpenGeo — Dutch integrated real-time transit data',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote:
|
||
'You are free to use this data, but there is no service level agreement (best-effort) nor are you allowed to say you represent or impersonate any of the transit agencies listed here.',
|
||
note: 'The README asks consumers to identify themselves in the User-Agent, to send If-Modified-Since / If-None-Match when polling faster than once a minute, and to accept gzip. The proxy does all three.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
}),
|
||
Object.freeze({
|
||
id: 'entur-norway',
|
||
name: 'Entur',
|
||
operator: 'Entur AS (Norwegian national transit data)',
|
||
region: 'Norway',
|
||
// Circle chosen to hold Oslo, Bergen, Bodø and Tromsø while leaving
|
||
// Helsinki (≈820 km) out — a national feed must not poll from next door.
|
||
center: Object.freeze({ lat: 64.0, lon: 11.5 }),
|
||
loadRadiusKm: 720,
|
||
url: 'https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions',
|
||
headers: Object.freeze({ 'ET-Client-Name': 'gods-eye-view-transit' }),
|
||
license: 'Norwegian Licence for Open Government Data (NLOD)',
|
||
licenseUrl: 'https://developer.entur.org/pages-intro-authentication',
|
||
attribution: 'Entur — data under NLOD',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote:
|
||
'This API is open under NLOD licence, however, it is required that all consumers identify themselves by using the header ET-Client-Name.',
|
||
note: 'The header is mandatory — unidentified consumers may be rate-limited or blocked — and the proxy sends it on every request.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
routeMode: enturRouteMode,
|
||
}),
|
||
Object.freeze({
|
||
id: 'translink-seq',
|
||
name: 'TransLink',
|
||
operator: 'TransLink (Queensland Government)',
|
||
region: 'South East Queensland, Australia',
|
||
center: Object.freeze({ lat: -27.4698, lon: 153.0251 }),
|
||
loadRadiusKm: 150,
|
||
url: 'https://gtfsrt.api.translink.com.au/api/realtime/seq/VehiclePositions',
|
||
license: 'CC BY 4.0',
|
||
licenseUrl: 'https://translink.com.au/about-translink/open-data',
|
||
attribution: 'TransLink — Queensland Government (CC BY 4.0)',
|
||
defaultEnabled: true,
|
||
terms: Object.freeze({
|
||
quote:
|
||
'Our data is licensed under a Creative Commons Attribution 4.0 International License. ... You must not represent that the State in any way endorses your Application.',
|
||
note: 'No key, no stated rate limit, no caching rule. Logos and network imagery need separate approval, so the credit is text only.',
|
||
}),
|
||
defaultMode: 'bus',
|
||
}),
|
||
]);
|
||
|
||
/**
|
||
* The feeds this build actually polls. A registered feed that is not here is
|
||
* inert everywhere: it resolves to no route, is never offered to the browser,
|
||
* and never appears in a coverage check.
|
||
*/
|
||
export const TRANSIT_ENABLED_FEEDS = Object.freeze(
|
||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true),
|
||
);
|
||
|
||
const FEED_BY_ID = new Map(
|
||
TRANSIT_ENABLED_FEEDS.map((feed) => [feed.id, feed]),
|
||
);
|
||
const ANY_FEED_BY_ID = new Map(
|
||
TRANSIT_FEED_REGISTRY.map((feed) => [feed.id, feed]),
|
||
);
|
||
|
||
/** Feed ids are path segments: lowercase letters, digits, hyphens only. */
|
||
export const TRANSIT_FEED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
||
|
||
/**
|
||
* Look up a POLLABLE feed by id. Unknown ids, malformed ids, and feeds that are
|
||
* registered but switched off all return null — this is the only door from a
|
||
* request path to an upstream URL, so a feed the owner has not cleared must not
|
||
* be reachable through it.
|
||
* @param {string} id
|
||
* @returns {object|null}
|
||
*/
|
||
export function getTransitFeed(id) {
|
||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||
return FEED_BY_ID.get(id) || null;
|
||
}
|
||
|
||
/**
|
||
* Look up any registered feed, enabled or not. For credits and documentation
|
||
* only — never for resolving a request into an upstream fetch.
|
||
* @param {string} id
|
||
* @returns {object|null}
|
||
*/
|
||
export function getRegisteredTransitFeed(id) {
|
||
if (typeof id !== 'string' || !TRANSIT_FEED_ID_PATTERN.test(id)) return null;
|
||
return ANY_FEED_BY_ID.get(id) || null;
|
||
}
|
||
|
||
/**
|
||
* Great-circle distance in km.
|
||
* @param {number} aLat
|
||
* @param {number} aLon
|
||
* @param {number} bLat
|
||
* @param {number} bLon
|
||
* @returns {number}
|
||
*/
|
||
export function haversineKm(aLat, aLon, bLat, bLon) {
|
||
const toRad = (deg) => (deg * Math.PI) / 180;
|
||
const dLat = toRad(bLat - aLat);
|
||
const dLon = toRad(bLon - aLon);
|
||
const h =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
||
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h)));
|
||
}
|
||
|
||
/**
|
||
* Feeds whose coverage circle contains the point, nearest first.
|
||
* @param {number} lat
|
||
* @param {number} lon
|
||
* @param {number} [slackKm=0] Extra radius (hysteresis) so a feed at the edge
|
||
* of coverage does not flap on small camera moves.
|
||
* @returns {object[]}
|
||
*/
|
||
export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return [];
|
||
return TRANSIT_ENABLED_FEEDS.map((feed) => ({
|
||
feed,
|
||
km: haversineKm(lat, lon, feed.center.lat, feed.center.lon),
|
||
}))
|
||
.filter(({ feed, km }) => km <= feed.loadRadiusKm + Math.max(0, slackKm))
|
||
.sort((a, b) => a.km - b.km)
|
||
.map(({ feed }) => feed);
|
||
}
|
||
|
||
/**
|
||
* Mode for a vehicle: the feed's route hint when it has one, else its default.
|
||
* @param {object} feed Registry entry.
|
||
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
|
||
* @returns {string} One of TRANSIT_MODES.
|
||
*/
|
||
export function transitModeFor(feed, routeId) {
|
||
const hinted =
|
||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||
const mode =
|
||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||
}
|
||
|
||
/**
|
||
* Whether the mode came from the route id rather than the feed's default. A
|
||
* default is a guess about a fleet — TransLink's is "bus" while it publishes
|
||
* rail in the same feed — so a consumer judging physical plausibility must
|
||
* not hold a defaulted vehicle to a bus's limits.
|
||
* @param {object} feed Registry entry.
|
||
* @param {string|null} routeId
|
||
* @returns {boolean}
|
||
*/
|
||
export function transitModeResolved(feed, routeId) {
|
||
const hinted =
|
||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||
return (
|
||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||
* @returns {object[]}
|
||
*/
|
||
export function publicTransitCatalog() {
|
||
return TRANSIT_ENABLED_FEEDS.map((feed) => ({
|
||
id: feed.id,
|
||
name: feed.name,
|
||
operator: feed.operator,
|
||
region: feed.region,
|
||
center: { ...feed.center },
|
||
loadRadiusKm: feed.loadRadiusKm,
|
||
license: feed.license,
|
||
licenseUrl: feed.licenseUrl,
|
||
attribution: feed.attribution,
|
||
historyRetention: feed.historyRetention === true,
|
||
}));
|
||
}
|