From e56769d14bb7a2f8c8e0171a108bfa40a62822ca Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Fri, 8 May 2026 18:13:33 -0500 Subject: [PATCH 01/10] Render routes and stations from per-provider PMTiles Replace the client-side GTFS shape and station rendering with MapLibre VectorSources pointing at one .pmtiles file per provider (amtrak, brightline, cta, metra, metrotransit, trirail). Routes render from the transit_routes source-layer with each route's authored color; stations render from transit_stops as circles with zoom-interpolated radius. Per-provider min-zoom thresholds keep larger systems (Amtrak) visible from low zooms while local systems only appear when the user zooms into their service area. The old useShapes/useStations hooks, AnimatedRoute, AnimatedStationMarker, and station clustering are no longer rendered (kept on disk for now; deleted in the API-rewiring step). Also fix an initial-camera bug surfaced by the rewrite: when location permission resolves after the first render, Camera.initialViewState is already evaluated, so the map stayed at world view. Jump the camera to the resolved region the first time mapReady flips true. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/app.config.ts | 5 + apps/mobile/components/map/ProviderTiles.tsx | 156 +++++++++++++++++++ apps/mobile/constants/config.ts | 15 ++ apps/mobile/constants/providers.ts | 60 +++++++ apps/mobile/screens/MapScreen.tsx | 126 +++++---------- 5 files changed, 279 insertions(+), 83 deletions(-) create mode 100644 apps/mobile/components/map/ProviderTiles.tsx create mode 100644 apps/mobile/constants/config.ts create mode 100644 apps/mobile/constants/providers.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 72c078b..ed745bc 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,6 +1,8 @@ import "dotenv/config"; import type { ExpoConfig } from "expo/config"; +declare const process: { env: Record }; + const config: ExpoConfig = { name: "Tracky", slug: "tracky", @@ -124,6 +126,9 @@ const config: ExpoConfig = { eas: { "projectId": "f1a6b072-9cd4-4965-956c-8b60bdfba2e1" }, + apiUrl: process.env.EXPO_PUBLIC_API_URL ?? "https://api.trackyapp.net", + wsUrl: process.env.EXPO_PUBLIC_WS_URL ?? "wss://api.trackyapp.net/ws/realtime", + tilesUrl: process.env.EXPO_PUBLIC_TILES_URL ?? "https://tiles.trytracky.com", }, owner: "railforless", }; diff --git a/apps/mobile/components/map/ProviderTiles.tsx b/apps/mobile/components/map/ProviderTiles.tsx new file mode 100644 index 0000000..a79bfd9 --- /dev/null +++ b/apps/mobile/components/map/ProviderTiles.tsx @@ -0,0 +1,156 @@ +import React, { useCallback } from 'react'; +import type { NativeSyntheticEvent } from 'react-native'; +import { Layer, VectorSource } from '@maplibre/maplibre-react-native'; +import type { Provider } from '../../constants/providers'; +import { config } from '../../constants/config'; + +export interface StationTapPayload { + providerId: string; + stopCode: string; + stopId: string; + name: string; + lat: number; + lon: number; +} + +export interface RouteTapPayload { + providerId: string; + routeId: string; + shortName?: string; + longName?: string; + color?: string; +} + +interface ProviderTilesProps { + provider: Provider; + stationColor: string; + stationStrokeColor: string; + labelColor: string; + labelHaloColor: string; + onStationPress?: (payload: StationTapPayload) => void; + onRoutePress?: (payload: RouteTapPayload) => void; +} + +interface PressEventLike { + features?: GeoJSON.Feature[]; + coordinates?: { latitude: number; longitude: number }; +} + +export const ProviderTiles = React.memo(function ProviderTiles({ + provider, + stationColor, + stationStrokeColor, + labelColor, + labelHaloColor, + onStationPress, + onRoutePress, +}: ProviderTilesProps) { + const url = `pmtiles://${config.tilesUrl}/${provider.id}.pmtiles`; + const sourceId = `tiles-${provider.id}`; + const routeLayerId = `routes-${provider.id}`; + const stationCircleLayerId = `stations-circle-${provider.id}`; + const stationLabelLayerId = `stations-label-${provider.id}`; + + const handlePress = useCallback( + (event: NativeSyntheticEvent) => { + const features = event.nativeEvent?.features ?? []; + const top = features[0]; + if (!top) return; + const props = (top.properties ?? {}) as Record; + + if (typeof props.code === 'string') { + if (!onStationPress) return; + const geom = top.geometry as GeoJSON.Point | undefined; + const [lon, lat] = geom?.type === 'Point' ? geom.coordinates : [0, 0]; + onStationPress({ + providerId: typeof props.provider_id === 'string' ? props.provider_id : provider.id, + stopCode: props.code, + stopId: typeof props.stop_id === 'string' ? props.stop_id : `${provider.id}:${props.code}`, + name: typeof props.name === 'string' ? props.name : '', + lat, + lon, + }); + return; + } + + if (typeof props.route_id === 'string') { + if (!onRoutePress) return; + onRoutePress({ + providerId: typeof props.provider_id === 'string' ? props.provider_id : provider.id, + routeId: props.route_id, + shortName: typeof props.short_name === 'string' ? props.short_name : undefined, + longName: typeof props.long_name === 'string' ? props.long_name : undefined, + color: typeof props.color === 'string' ? props.color : undefined, + }); + } + }, + [provider.id, onStationPress, onRoutePress], + ); + + return ( + + + + + + ); +}); diff --git a/apps/mobile/constants/config.ts b/apps/mobile/constants/config.ts new file mode 100644 index 0000000..d4b14f9 --- /dev/null +++ b/apps/mobile/constants/config.ts @@ -0,0 +1,15 @@ +import Constants from 'expo-constants'; + +interface AppExtra { + apiUrl?: string; + wsUrl?: string; + tilesUrl?: string; +} + +const extra = (Constants.expoConfig?.extra ?? {}) as AppExtra; + +export const config = { + apiUrl: extra.apiUrl ?? 'https://api.trackyapp.net', + wsUrl: extra.wsUrl ?? 'wss://api.trackyapp.net/ws/realtime', + tilesUrl: extra.tilesUrl ?? 'https://tiles.trytracky.com', +}; diff --git a/apps/mobile/constants/providers.ts b/apps/mobile/constants/providers.ts new file mode 100644 index 0000000..5c5a52e --- /dev/null +++ b/apps/mobile/constants/providers.ts @@ -0,0 +1,60 @@ +export type ProviderId = + | 'amtrak' + | 'brightline' + | 'cta' + | 'metra' + | 'metrotransit' + | 'trirail'; + +export interface Provider { + id: ProviderId; + displayName: string; + routeMinZoom: number; + stationMinZoom: number; + stationLabelMinZoom: number; +} + +export const PROVIDERS: readonly Provider[] = [ + { + id: 'amtrak', + displayName: 'Amtrak', + routeMinZoom: 3, + stationMinZoom: 5, + stationLabelMinZoom: 8, + }, + { + id: 'brightline', + displayName: 'Brightline', + routeMinZoom: 6, + stationMinZoom: 7, + stationLabelMinZoom: 9, + }, + { + id: 'cta', + displayName: 'CTA', + routeMinZoom: 9, + stationMinZoom: 10, + stationLabelMinZoom: 12, + }, + { + id: 'metra', + displayName: 'Metra', + routeMinZoom: 8, + stationMinZoom: 9, + stationLabelMinZoom: 11, + }, + { + id: 'metrotransit', + displayName: 'Metro Transit', + routeMinZoom: 9, + stationMinZoom: 10, + stationLabelMinZoom: 12, + }, + { + id: 'trirail', + displayName: 'Tri-Rail', + routeMinZoom: 7, + stationMinZoom: 8, + stationLabelMinZoom: 10, + }, +]; diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index caeb4e5..a6326ad 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -6,9 +6,8 @@ import { Camera, CameraRef, GeoJSONSource, Layer, Map, Marker, UserLocation, Vie import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Ionicons from 'react-native-vector-icons/Ionicons'; import { ErrorBoundary } from '../components/ErrorBoundary'; -import { AnimatedRoute } from '../components/map/AnimatedRoute'; -import { AnimatedStationMarker } from '../components/map/AnimatedStationMarker'; import { LiveTrainMarker } from '../components/map/LiveTrainMarker'; +import { ProviderTiles, type StationTapPayload } from '../components/map/ProviderTiles'; import MapSettingsPill, { MapType, RouteMode, StationMode, TrainMode } from '../components/map/MapSettingsPill'; import DepartureBoardModal from '../components/ui/DepartureBoardModal'; import ProfileModal from '../components/ui/ProfileModal'; @@ -38,19 +37,15 @@ import { UnitsProvider } from '../context/UnitsContext'; import { useLiveTrains } from '../hooks/useLiveTrains'; import { useMapLocation } from '../hooks/useMapLocation'; import { useRealtime } from '../hooks/useRealtime'; -import { useShapes } from '../hooks/useShapes'; -import { useStations } from '../hooks/useStations'; import { useTravelOverlay } from '../hooks/useTravelOverlay'; +import { PROVIDERS } from '../constants/providers'; import { TrainAPIService } from '../services/api'; import { requestPermissions as requestNotificationPermissions } from '../services/notifications'; import { TrainStorageService } from '../services/storage'; import type { SavedTrainRef, Stop, Train, ViewportBounds } from '../types/train'; -import { ClusteringConfig } from '../utils/clustering-config'; import { gtfsParser } from '../utils/gtfs-parser'; import { light as hapticLight } from '../utils/haptics'; import { logger } from '../utils/logger'; -import { getRouteColor, getStrokeWidthForZoom } from '../utils/route-colors'; -import { clusterStations, getStationAbbreviation } from '../utils/station-clustering'; import { clusterTrains, type TrainCluster } from '../utils/train-clustering'; import { ModalContent, ModalContentHandle } from './ModalContent'; import { createStyles } from './styles'; @@ -243,10 +238,6 @@ function MapScreenInner() { return () => clearTimeout(timer); }, [isOverlayMode, travelStations]); - // Use lazy-loaded stations and shapes based on viewport - const stations = useStations(viewportBounds ?? undefined); - const { visibleShapes } = useShapes(viewportBounds ?? undefined); - // Fetch all live trains from GTFS-RT (only when trainMode is 'all') const { liveTrains } = useLiveTrains(15000, trainMode === 'all'); @@ -450,17 +441,6 @@ function MapScreenInner() { [navigateToStation, fitMapToCoordinates] ); - // Stable callback for station marker presses — receives cluster from child - const handleStationMarkerPress = useCallback((cluster: { - id: string; - lat: number; - lon: number; - isCluster: boolean; - stations: Array<{ id: string; name: string; lat: number; lon: number }>; - }) => { - handleStationPress(cluster); - }, [handleStationPress]); - // Stable callback for saved train cluster presses const handleSavedTrainClusterPress = useCallback((cluster: TrainCluster) => { if (cluster.isCluster) { @@ -658,15 +638,23 @@ function MapScreenInner() { }, VIEWPORT_DEBOUNCE_MS); }, []); - // Initialize viewport bounds when map first becomes ready + // When location resolves AFTER the map mounts, the Camera's initialViewState + // (read once at mount) leaves the camera at world view. Jump it into place. + const initialCameraSet = useRef(false); React.useEffect(() => { - if (mapReady && regionRef.current && !viewportBounds) { + if (mapReady && regionRef.current && !initialCameraSet.current) { + initialCameraSet.current = true; + const r = regionRef.current; + cameraRef.current?.jumpTo({ + center: [r.longitude, r.latitude], + zoom: latDeltaToZoom(r.latitudeDelta), + }); setViewportState({ - bounds: regionToViewportBounds(regionRef.current), - latDelta: regionRef.current.latitudeDelta, + bounds: regionToViewportBounds(r), + latDelta: r.latitudeDelta, }); } - }, [mapReady, viewportBounds]); + }, [mapReady]); // Cleanup timers on unmount React.useEffect(() => { @@ -700,30 +688,22 @@ function MapScreenInner() { } }, [getCurrentSnap]); - // Calculate dynamic stroke width based on zoom level - const baseStrokeWidth = useMemo(() => { - return getStrokeWidthForZoom(debouncedLatDelta); - }, [debouncedLatDelta]); - - // Routes are always visible (no zoom-based fading) const shouldRenderRoutes = routeMode !== 'hidden'; - - // Cluster stations based on zoom level and station mode - const stationClusters = useMemo(() => { - if (stationMode === 'hidden') return []; - if (stationMode === 'all') { - // Return all stations without clustering - return stations.map(s => ({ - id: s.id, - lat: s.lat, - lon: s.lon, + const shouldRenderStations = stationMode !== 'hidden'; + + // Adapter: convert PMTiles station tap → existing handleStationPress contract + const handleProviderTileStationPress = useCallback( + (payload: StationTapPayload) => { + handleStationPress({ + id: payload.stopId, + lat: payload.lat, + lon: payload.lon, isCluster: false, - stations: [s], - })); - } - // 'auto' mode - use clustering - return clusterStations(stations, debouncedLatDelta); - }, [stations, debouncedLatDelta, stationMode]); + stations: [{ id: payload.stopCode, name: payload.name, lat: payload.lat, lon: payload.lon }], + }); + }, + [handleStationPress], + ); return ( @@ -744,40 +724,18 @@ function MapScreenInner() { {showNormalMapContent && - shouldRenderRoutes && - visibleShapes.map(shape => { - const colorScheme = getRouteColor(shape.id, colors.accentBlue); - return ( - - ); - })} - - {showNormalMapContent && - stationClusters.map(cluster => { - // Show full name when zoomed in enough - const showFullName = !cluster.isCluster && debouncedLatDelta < ClusteringConfig.fullNameThreshold; - const displayName = cluster.isCluster - ? `${cluster.stations.length}+` - : showFullName - ? cluster.stations[0].name - : getStationAbbreviation(cluster.stations[0].id, cluster.stations[0].name); - return ( - - ); - })} + (shouldRenderRoutes || shouldRenderStations) && + PROVIDERS.map(provider => ( + + ))} {/* Render saved trains when mode is 'saved' */} {showNormalMapContent && @@ -1004,3 +962,5 @@ export default function MapScreen() { ); } + + From 5c5f6df94ee41af7e545665a09a17f18dadbf795 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Fri, 8 May 2026 18:22:48 -0500 Subject: [PATCH 02/10] Add typed API client, WebSocket client, and realtime context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/mobile now has a thin layer for talking to api.trackyapp.net: - types/api.ts mirrors apps/api/spec.* and the static_read.go response shapes one-for-one (camelCase), so spec changes on the backend show up as TS errors here. - services/api-client.ts wraps every /v1/* endpoint we use (provider, stop, route, route/trains, trip, trip/stops, trips/lookup, departures, connections, trains/service, search) with a 1h in-memory cache for cacheable lookups. Three pending endpoints (run stops, stops/nearby, run position) are stubbed so call sites can be written ahead of the backend landing them. - services/ws-client.ts is a singleton WebSocket manager: opens on first subscribe, ref-counts provider topics, re-sends subscribe on reconnect (exponential backoff to 30s), and closes when the last listener unsubscribes. - context/RealtimeContext.tsx wraps the WS singleton, subscribes to all providers on mount, and exposes useRealtimePositions(provider?) for consumers. No call sites are wired up yet — the existing GTFS/Transitdocs paths still power live trains, search, and modals. The next commit swaps consumers over to these new services. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/context/RealtimeContext.tsx | 87 ++++++++ apps/mobile/services/api-client.ts | 274 ++++++++++++++++++++++++ apps/mobile/services/ws-client.ts | 212 ++++++++++++++++++ apps/mobile/types/api.ts | 151 +++++++++++++ 4 files changed, 724 insertions(+) create mode 100644 apps/mobile/context/RealtimeContext.tsx create mode 100644 apps/mobile/services/api-client.ts create mode 100644 apps/mobile/services/ws-client.ts create mode 100644 apps/mobile/types/api.ts diff --git a/apps/mobile/context/RealtimeContext.tsx b/apps/mobile/context/RealtimeContext.tsx new file mode 100644 index 0000000..82264f5 --- /dev/null +++ b/apps/mobile/context/RealtimeContext.tsx @@ -0,0 +1,87 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { PROVIDERS } from '../constants/providers'; +import { wsClient } from '../services/ws-client'; +import type { ApiTrainPosition, RealtimeUpdate } from '../types/api'; + +type PositionsByProvider = Record; + +interface RealtimeContextValue { + positionsByProvider: PositionsByProvider; +} + +const EMPTY_POSITIONS: PositionsByProvider = {}; + +const RealtimeContext = createContext({ + positionsByProvider: EMPTY_POSITIONS, +}); + +interface RealtimeProviderProps { + /** Defaults to all six known providers. */ + providers?: readonly string[]; + children: React.ReactNode; +} + +/** + * Subscribes to the realtime WebSocket and exposes the latest positions + * per provider via context. Updates are coalesced into a single state + * update per WS message. + */ +export function RealtimeProvider({ providers, children }: RealtimeProviderProps) { + const providerIds = useMemo( + () => providers ?? PROVIDERS.map(p => p.id), + [providers], + ); + + const [positionsByProvider, setPositionsByProvider] = + useState(EMPTY_POSITIONS); + + // Hold the most recent positions in a ref so we can produce a stable + // updater function below. + const latestRef = useRef(EMPTY_POSITIONS); + + useEffect(() => { + const ids = [...providerIds]; + const onUpdate = (msg: RealtimeUpdate) => { + const next: PositionsByProvider = { + ...latestRef.current, + [msg.provider]: msg.positions, + }; + latestRef.current = next; + setPositionsByProvider(next); + }; + const unsubscribe = wsClient.subscribe(ids, onUpdate); + return unsubscribe; + }, [providerIds]); + + const value = useMemo( + () => ({ positionsByProvider }), + [positionsByProvider], + ); + + return {children}; +} + +/** + * All known live train positions, optionally filtered to a single provider. + * Returns a stable empty array when no data has arrived yet for the + * requested provider. + */ +export function useRealtimePositions(provider?: string): ApiTrainPosition[] { + const { positionsByProvider } = useContext(RealtimeContext); + return useMemo(() => { + if (provider) return positionsByProvider[provider] ?? []; + const all: ApiTrainPosition[] = []; + for (const list of Object.values(positionsByProvider)) { + for (const p of list) all.push(p); + } + return all; + }, [positionsByProvider, provider]); +} diff --git a/apps/mobile/services/api-client.ts b/apps/mobile/services/api-client.ts new file mode 100644 index 0000000..b31886a --- /dev/null +++ b/apps/mobile/services/api-client.ts @@ -0,0 +1,274 @@ +/** + * Typed client for the Tracky backend REST API at `apiUrl` (config.ts). + * + * Path structure mirrors apps/api routes — see /v1/* in apps/api/routes/static.go + * for endpoint definitions and apps/api/db/static_read.go for response shapes. + * + * Static lookups are cached in-memory for 1 hour (matches server Cache-Control). + * Time-sensitive endpoints (departures, lookups, runs) skip the cache. + */ + +import { config } from '../constants/config'; +import { fetchWithTimeout } from '../utils/fetch-with-timeout'; +import type { + ApiAgency, + ApiConnectionItem, + ApiDepartureItem, + ApiEnrichedStopTime, + ApiRoute, + ApiSearchHitType, + ApiSearchResult, + ApiServiceInfo, + ApiStop, + ApiTrainItem, + ApiTrainPosition, + ApiTrainStopTime, + ApiTrip, +} from '../types/api'; + +const STATIC_TTL_MS = 60 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 12_000; + +interface CacheEntry { + value: T; + expiresAt: number; +} +const cache = new Map>(); + +function getCached(key: string): T | undefined { + const entry = cache.get(key); + if (!entry) return undefined; + if (entry.expiresAt < Date.now()) { + cache.delete(key); + return undefined; + } + return entry.value as T; +} + +function setCached(key: string, value: T, ttlMs: number): void { + cache.set(key, { value, expiresAt: Date.now() + ttlMs }); +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly path: string, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +function buildQuery(params: Record): string { + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); + if (entries.length === 0) return ''; + const search = new URLSearchParams(); + for (const [k, v] of entries) search.set(k, String(v)); + return `?${search.toString()}`; +} + +async function request( + path: string, + opts: { cacheKey?: string; ttlMs?: number; timeoutMs?: number } = {}, +): Promise { + if (opts.cacheKey) { + const hit = getCached(opts.cacheKey); + if (hit !== undefined) return hit; + } + + const url = `${config.apiUrl}${path}`; + const res = await fetchWithTimeout(url, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS }); + + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body?.error) message = body.error; + } catch { + // body was not JSON; keep status-only message + } + throw new ApiError(res.status, path, message); + } + + const value = (await res.json()) as T; + if (opts.cacheKey && opts.ttlMs) { + setCached(opts.cacheKey, value, opts.ttlMs); + } + return value; +} + +// ── Providers ────────────────────────────────────────────────────────────── + +export function getProvider(providerId: string): Promise { + return request(`/v1/providers/${encodeURIComponent(providerId)}`, { + cacheKey: `provider:${providerId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +// ── Stops ────────────────────────────────────────────────────────────────── + +export function getStop(providerId: string, stopCode: string): Promise { + return request( + `/v1/stops/${encodeURIComponent(providerId)}/${encodeURIComponent(stopCode)}`, + { cacheKey: `stop:${providerId}:${stopCode}`, ttlMs: STATIC_TTL_MS }, + ); +} + +// ── Routes ───────────────────────────────────────────────────────────────── + +export function getRoute(providerId: string, routeCode: string): Promise { + return request( + `/v1/routes/${encodeURIComponent(providerId)}/${encodeURIComponent(routeCode)}`, + { cacheKey: `route:${providerId}:${routeCode}`, ttlMs: STATIC_TTL_MS }, + ); +} + +export function getTrainsForRoute( + providerId: string, + routeCode: string, +): Promise { + return request( + `/v1/routes/${encodeURIComponent(providerId)}/${encodeURIComponent(routeCode)}/trains`, + { cacheKey: `routeTrains:${providerId}:${routeCode}`, ttlMs: STATIC_TTL_MS }, + ); +} + +// ── Trips ────────────────────────────────────────────────────────────────── + +export function getTrip(tripId: string): Promise { + return request(`/v1/trips/${encodeURIComponent(tripId)}`, { + cacheKey: `trip:${tripId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +export function getTripStops(tripId: string): Promise { + return request(`/v1/trips/${encodeURIComponent(tripId)}/stops`, { + cacheKey: `tripStops:${tripId}`, + ttlMs: STATIC_TTL_MS, + }); +} + +export function lookupTrips(params: { + provider: string; + trainNumber: string; + date: string; +}): Promise { + const qs = buildQuery({ + provider: params.provider, + train_number: params.trainNumber, + date: params.date, + }); + return request(`/v1/trips/lookup${qs}`); +} + +// ── Departures & connections ─────────────────────────────────────────────── + +export function getDepartures(params: { + stopId: string; + date: string; +}): Promise { + const qs = buildQuery({ stop_id: params.stopId, date: params.date }); + return request(`/v1/departures${qs}`); +} + +export function getConnections(params: { + fromStop: string; + toStop: string; + date: string; +}): Promise { + const qs = buildQuery({ + from_stop: params.fromStop, + to_stop: params.toStop, + date: params.date, + }); + return request(`/v1/connections${qs}`); +} + +// ── Train service ────────────────────────────────────────────────────────── + +export function getTrainService( + trainNumber: string, + params: { provider: string; from?: string; to?: string }, +): Promise { + const qs = buildQuery({ provider: params.provider, from: params.from, to: params.to }); + return request( + `/v1/trains/${encodeURIComponent(trainNumber)}/service${qs}`, + ); +} + +// ── Search ───────────────────────────────────────────────────────────────── + +export function search(params: { + q: string; + provider?: string; + types?: ApiSearchHitType[]; +}): Promise { + const qs = buildQuery({ + q: params.q, + provider: params.provider, + types: params.types?.join(','), + }); + return request(`/v1/search${qs}`); +} + +// ── Pending backend additions ────────────────────────────────────────────── +// +// The endpoints below aren't on the build-backend branch yet. They're stubbed +// here so call sites can be written; flip them on once the backend lands. + +/** + * Per-stop scheduled + estimated + actual times for a specific run of a trip. + * Required for TrainDetailModal's per-stop delay display. + * + * Expected: GET /v1/runs/{provider}/{tripId}/{runDate}/stops + */ +export function getRunStops(_params: { + provider: string; + tripId: string; + runDate: string; +}): Promise { + return Promise.reject( + new Error('getRunStops: backend endpoint not yet implemented'), + ); +} + +/** + * Stops near a location across all providers, for "nearest station" suggestions. + * + * Expected: GET /v1/stops/nearby?lat=&lon=&radius_m= + */ +export function getNearbyStops(_params: { + lat: number; + lon: number; + radiusMeters?: number; +}): Promise { + return Promise.reject( + new Error('getNearbyStops: backend endpoint not yet implemented'), + ); +} + +/** + * Current TrainPosition for a specific run, for hydrating saved trains + * without subscribing to the full WS feed. (Optional — workaround is to + * subscribe + filter.) + * + * Expected: GET /v1/runs/{provider}/{tripId}/{runDate} + */ +export function getRunPosition(_params: { + provider: string; + tripId: string; + runDate: string; +}): Promise { + return Promise.reject( + new Error('getRunPosition: backend endpoint not yet implemented'), + ); +} + +// ── Cache utilities (mostly for tests / sign-out) ────────────────────────── + +export function clearApiCache(): void { + cache.clear(); +} diff --git a/apps/mobile/services/ws-client.ts b/apps/mobile/services/ws-client.ts new file mode 100644 index 0000000..8a5cf3b --- /dev/null +++ b/apps/mobile/services/ws-client.ts @@ -0,0 +1,212 @@ +/** + * Singleton WebSocket client for the realtime feed at `wsUrl` (config.ts). + * + * Usage: + * const off = wsClient.subscribe(['amtrak'], (update) => { ... }); + * off(); // unsubscribe + drop topic if no other listeners want it + * + * Wire format: see apps/api/ws/poller.go (RealtimeUpdate envelope). + * Subscription protocol: see apps/api/ws/handler.go (clientMsg). + */ + +import { config } from '../constants/config'; +import type { RealtimeUpdate } from '../types/api'; +import { logger } from '../utils/logger'; + +type Listener = (update: RealtimeUpdate) => void; +type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed'; + +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; + +interface ProviderSubscription { + refCount: number; + /** True once the server has acknowledged (i.e. we sent subscribe while open). */ + sentToServer: boolean; +} + +class WSClient { + private ws: WebSocket | null = null; + private state: ConnectionState = 'idle'; + private listeners = new Set(); + private subscriptions = new Map(); + private reconnectAttempts = 0; + private reconnectTimer: ReturnType | null = null; + private intentionallyClosed = false; + + /** + * Subscribe a listener to one or more providers. Returns an unsubscribe + * function. Listeners are invoked for *every* RealtimeUpdate the socket + * delivers — filter by `update.provider` in the listener if needed. + */ + subscribe(providers: string[], listener: Listener): () => void { + this.listeners.add(listener); + + for (const p of providers) { + const existing = this.subscriptions.get(p); + if (existing) { + existing.refCount += 1; + } else { + this.subscriptions.set(p, { refCount: 1, sentToServer: false }); + } + } + + this.ensureConnected(); + this.flushSubscribeIfOpen(providers); + + return () => { + this.listeners.delete(listener); + const drop: string[] = []; + for (const p of providers) { + const s = this.subscriptions.get(p); + if (!s) continue; + s.refCount -= 1; + if (s.refCount <= 0) { + drop.push(p); + this.subscriptions.delete(p); + } + } + if (drop.length > 0 && this.ws?.readyState === WebSocket.OPEN) { + this.send({ action: 'unsubscribe', providers: drop }); + } + // No listeners → close the socket so we don't hold a connection open. + if (this.listeners.size === 0) { + this.close(); + } + }; + } + + /** + * Permanently close the socket and cancel any pending reconnect. Subscribe() + * after close() is supported and will re-open. + */ + close(): void { + this.intentionallyClosed = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + try { + this.ws.close(); + } catch (e) { + logger.warn('[ws-client] error closing socket', e); + } + this.ws = null; + } + this.state = 'closed'; + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private ensureConnected(): void { + if (this.state === 'connecting' || this.state === 'open') return; + this.intentionallyClosed = false; + this.connect(); + } + + private connect(): void { + this.state = 'connecting'; + try { + this.ws = new WebSocket(config.wsUrl); + } catch (err) { + logger.error('[ws-client] WebSocket constructor threw', err); + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + this.state = 'open'; + this.reconnectAttempts = 0; + // (Re-)subscribe everything we had registered. + const providers = Array.from(this.subscriptions.keys()); + if (providers.length > 0) { + this.send({ action: 'subscribe', providers }); + for (const p of providers) { + const s = this.subscriptions.get(p); + if (s) s.sentToServer = true; + } + } + logger.debug(`[ws-client] connected, subscribed to ${providers.join(',')}`); + }; + + this.ws.onmessage = event => { + let parsed: unknown; + try { + parsed = typeof event.data === 'string' ? JSON.parse(event.data) : null; + } catch { + logger.warn('[ws-client] non-JSON message', event.data); + return; + } + if (!isRealtimeUpdate(parsed)) return; + for (const l of this.listeners) { + try { + l(parsed); + } catch (e) { + logger.error('[ws-client] listener threw', e); + } + } + }; + + this.ws.onerror = err => { + logger.warn('[ws-client] socket error', err); + }; + + this.ws.onclose = () => { + this.state = 'closed'; + // Server-side or transport close. Mark all subscriptions as not-sent so + // the next connect re-sends them. + for (const s of this.subscriptions.values()) s.sentToServer = false; + this.ws = null; + if (!this.intentionallyClosed && this.listeners.size > 0) { + this.scheduleReconnect(); + } + }; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + const attempt = this.reconnectAttempts++; + const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS); + logger.debug(`[ws-client] reconnecting in ${delay}ms (attempt ${attempt + 1})`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private send(msg: { action: 'subscribe' | 'unsubscribe'; providers: string[] }): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + try { + this.ws.send(JSON.stringify(msg)); + } catch (e) { + logger.warn('[ws-client] send failed', e); + } + } + + private flushSubscribeIfOpen(providers: string[]): void { + if (this.ws?.readyState !== WebSocket.OPEN) return; + const toSend = providers.filter(p => { + const s = this.subscriptions.get(p); + return s && !s.sentToServer; + }); + if (toSend.length === 0) return; + this.send({ action: 'subscribe', providers: toSend }); + for (const p of toSend) { + const s = this.subscriptions.get(p); + if (s) s.sentToServer = true; + } + } +} + +function isRealtimeUpdate(value: unknown): value is RealtimeUpdate { + if (!value || typeof value !== 'object') return false; + const v = value as { type?: unknown; provider?: unknown; positions?: unknown }; + return ( + v.type === 'realtime_update' && + typeof v.provider === 'string' && + Array.isArray(v.positions) + ); +} + +export const wsClient = new WSClient(); diff --git a/apps/mobile/types/api.ts b/apps/mobile/types/api.ts new file mode 100644 index 0000000..c31d21e --- /dev/null +++ b/apps/mobile/types/api.ts @@ -0,0 +1,151 @@ +/** + * TypeScript mirrors of the backend `spec.*` and route response types. + * Field names match the JSON tags from apps/api (camelCase). + * + * Keep this file in sync with: + * - apps/api/spec/static.go + * - apps/api/spec/realtime.go + * - apps/api/db/static_read.go (response types: EnrichedStopTime, DepartureItem, etc.) + * - apps/api/ws/poller.go (RealtimeUpdate envelope) + */ + +export interface ApiAgency { + providerId: string; + gtfsAgencyId: string; + name: string; + url: string; + timezone: string; + lang: string | null; + phone: string | null; + country: string; +} + +export interface ApiRoute { + providerId: string; + routeId: string; + shortName: string; + longName: string; + color: string; + textColor: string; + shapeId: string | null; +} + +export interface ApiStop { + providerId: string; + stopId: string; + code: string; + name: string; + lat: number; + lon: number; + timezone: string | null; + wheelchairBoarding: boolean | null; +} + +export interface ApiTrip { + providerId: string; + tripId: string; + routeId: string; + serviceId: string; + shortName: string; + headsign: string; + shapeId: string | null; + directionId: number | null; +} + +export interface ApiScheduledStopTime { + providerId: string; + tripId: string; + stopId: string; + stopSequence: number; + arrivalTime: string | null; + departureTime: string | null; + timepoint: boolean | null; + dropOffType: number | null; + pickupType: number | null; +} + +export interface ApiEnrichedStopTime extends ApiScheduledStopTime { + stopName: string; + stopCode: string; +} + +export interface ApiDepartureItem extends ApiTrip { + arrivalTime: string | null; + departureTime: string | null; + stopSequence: number; +} + +export interface ApiConnectionItem extends ApiTrip { + from: ApiEnrichedStopTime; + to: ApiEnrichedStopTime; + intermediate: ApiEnrichedStopTime[]; +} + +export interface ApiTrainItem { + providerId: string; + trainNumber: string; + sampleHeadsign: string; + tripCount: number; +} + +export interface ApiServiceInfo { + providerId: string; + trainNumber: string; + minDate: string; + maxDate: string; +} + +export type ApiSearchHitType = 'station' | 'train' | 'route'; + +export interface ApiSearchHit { + type: ApiSearchHitType; + id: string; + name: string; + subtitle: string; + provider: string; +} + +export interface ApiSearchResult { + stations: ApiSearchHit[]; + trains: ApiSearchHit[]; + routes: ApiSearchHit[]; +} + +export type VehicleStopStatus = 'INCOMING_AT' | 'STOPPED_AT' | 'IN_TRANSIT_TO'; + +export interface ApiTrainPosition { + provider: string; + tripId: string; + runDate: string; + trainNumber: string; + routeId: string; + vehicleId: string; + lat: number | null; + lon: number | null; + heading: number | null; + speedMph: number | null; + currentStopCode: string | null; + currentStatus: VehicleStopStatus | null; + lastUpdated: string; +} + +export interface ApiTrainStopTime { + provider: string; + tripId: string; + runDate: string; + stopCode: string; + stopSequence: number; + scheduledArr: string | null; + scheduledDep: string | null; + estimatedArr: string | null; + estimatedDep: string | null; + actualArr: string | null; + actualDep: string | null; + lastUpdated: string; +} + +export interface RealtimeUpdate { + type: 'realtime_update'; + provider: string; + positions: ApiTrainPosition[]; +} From 9d98c4661b1fd9b5cc0522ee13e0fc50534729b9 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Fri, 8 May 2026 20:50:48 -0500 Subject: [PATCH 03/10] Rewire core data flows to API client + WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the highest-traffic consumers from local GTFS parsing / Transitdocs polling to the new backend: - services/api.ts (TrainAPIService) now goes through services/api-client. getTrainDetails resolves namespaced trip ids via /v1/trips/{tripId} or falls back to /v1/trips/lookup by train number + date. getTrainsForStation hits /v1/departures and fans out /v1/trips/{tripId}/stops per departure (1h cache makes repeats cheap). getTrainDetails attaches realtime position from the WS snapshot rather than polling Transitdocs. Drops the AMTRAK_ROUTE_NAMES table. - hooks/useLiveTrains is now a thin adapter over useRealtimePositions — no more 15s polling. The intervalMs prop is kept for backwards-compat but ignored. - hooks/useRealtime re-runs enrichment whenever the WS feed updates rather than on a 20s timer. - context/RealtimeContext prefetches route metadata for incoming routeIds so display sites resolve route names without a flicker. - services/ws-client tracks a per-provider snapshot and exposes findPosition() / getLatestPositions() for non-React readers. - services/api-client exposes getCachedRoute()/prefetchRoute() so render-time route lookups work without async. - hooks/useFrequentlyUsed swaps the bulk-fetch of routes/stops (gone with the GTFS parser) for a small static default list. - MapScreen tree wraps with RealtimeProvider; drops the GTFSRefresh provider and refresh-button plumbing. Loading overlay + GTFS-ready gating removed. Modal/search components that still go through gtfsParser directly (TrainDetailModal, TwoStationSearch, search, etc.) are left for the next pass — they're broken at runtime until the gtfsParser shim or direct API rewiring lands. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/context/RealtimeContext.tsx | 6 + apps/mobile/hooks/useFrequentlyUsed.ts | 53 +- apps/mobile/hooks/useLiveTrains.ts | 95 ++- apps/mobile/hooks/useRealtime.ts | 55 +- apps/mobile/screens/MapScreen.tsx | 43 +- apps/mobile/services/api-client.ts | 26 + apps/mobile/services/api.ts | 755 +++++++++--------------- apps/mobile/services/ws-client.ts | 26 +- 8 files changed, 435 insertions(+), 624 deletions(-) diff --git a/apps/mobile/context/RealtimeContext.tsx b/apps/mobile/context/RealtimeContext.tsx index 82264f5..0fc639c 100644 --- a/apps/mobile/context/RealtimeContext.tsx +++ b/apps/mobile/context/RealtimeContext.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import { PROVIDERS } from '../constants/providers'; +import { prefetchRoute } from '../services/api-client'; import { wsClient } from '../services/ws-client'; import type { ApiTrainPosition, RealtimeUpdate } from '../types/api'; @@ -50,6 +51,11 @@ export function RealtimeProvider({ providers, children }: RealtimeProviderProps) useEffect(() => { const ids = [...providerIds]; const onUpdate = (msg: RealtimeUpdate) => { + // Warm the route cache so display sites can resolve routeId → name + // synchronously without a flicker. + for (const p of msg.positions) { + if (p.routeId) prefetchRoute(p.routeId); + } const next: PositionsByProvider = { ...latestRef.current, [msg.provider]: msg.positions, diff --git a/apps/mobile/hooks/useFrequentlyUsed.ts b/apps/mobile/hooks/useFrequentlyUsed.ts index 61220f9..6156030 100644 --- a/apps/mobile/hooks/useFrequentlyUsed.ts +++ b/apps/mobile/hooks/useFrequentlyUsed.ts @@ -1,6 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; -import { TrainAPIService } from '../services/api'; -import { debug, error as logError } from '../utils/logger'; +import { useState } from 'react'; export interface FrequentlyUsedItem { id: string; @@ -10,39 +8,20 @@ export interface FrequentlyUsedItem { type: 'train' | 'station'; } -export function useFrequentlyUsed() { - const [items, setItems] = useState([]); - - const refresh = useCallback(async () => { - try { - const routes = await TrainAPIService.getRoutes(); - const stops = await TrainAPIService.getStops(); - const loaded = [ - ...routes.slice(0, 3).map((route, index) => ({ - id: `freq-route-${index}`, - name: route.route_long_name, - code: route.route_short_name || route.route_id.substring(0, 3), - subtitle: `AMT${route.route_id}`, - type: 'train' as const, - })), - ...stops.slice(0, 2).map((stop, index) => ({ - id: `freq-stop-${index}`, - name: stop.stop_name, - code: stop.stop_id, - subtitle: stop.stop_id, - type: 'station' as const, - })), - ]; - debug(`[useFrequentlyUsed] Loaded ${loaded.length} items`); - setItems(loaded); - } catch (err) { - logError('[useFrequentlyUsed] Failed to load frequently used items:', err); - } - }, []); +const DEFAULTS: FrequentlyUsedItem[] = [ + { id: 'freq-train-acela', type: 'train', name: 'Acela', code: '2151', subtitle: 'Northeast Corridor' }, + { id: 'freq-train-ner', type: 'train', name: 'Northeast Regional', code: '171', subtitle: 'Northeast Corridor' }, + { id: 'freq-train-cz', type: 'train', name: 'California Zephyr', code: '5', subtitle: 'Chicago → Emeryville' }, + { id: 'freq-stop-nyp', type: 'station', name: 'New York Penn', code: 'NYP', subtitle: 'NYP' }, + { id: 'freq-stop-chi', type: 'station', name: 'Chicago Union', code: 'CHI', subtitle: 'CHI' }, +]; - useEffect(() => { - refresh(); - }, [refresh]); - - return { items, refresh }; +/** + * Frequently-used items shown in the empty search state. Backed by a static + * default set for now — the previous bulk-load of all routes/stops is gone + * with the GTFS parser; a real "popular" list will come from the backend. + */ +export function useFrequentlyUsed() { + const [items] = useState(DEFAULTS); + return { items, refresh: () => Promise.resolve() }; } diff --git a/apps/mobile/hooks/useLiveTrains.ts b/apps/mobile/hooks/useLiveTrains.ts index 9d231b7..19e93d4 100644 --- a/apps/mobile/hooks/useLiveTrains.ts +++ b/apps/mobile/hooks/useLiveTrains.ts @@ -1,12 +1,12 @@ /** - * Hook for fetching all live trains from GTFS-RT feed - * Returns an array of all currently active trains with their positions + * Live trains feed for the map. Consumes the realtime WebSocket via + * RealtimeContext and adapts ApiTrainPosition into the LiveTrain shape + * that LiveTrainMarker expects. */ -import { useCallback, useEffect, useState } from 'react'; -import { RealtimeService } from '../services/realtime'; +import { useMemo } from 'react'; +import { useRealtimePositions } from '../context/RealtimeContext'; import { getTrainDisplayName } from '../services/api'; -import { logger } from '../utils/logger'; export interface LiveTrain { trainNumber: string; @@ -22,68 +22,41 @@ export interface LiveTrain { } /** - * Fetch all live trains from the GTFS-RT feed - * @param intervalMs - Refresh interval in milliseconds (default: 15000ms) - * @param enabled - Whether to enable polling (default: true) + * @param _intervalMs - kept for backwards compat; no-op now (WS-driven). + * @param enabled - when false, hide trains without unsubscribing. */ -export function useLiveTrains(intervalMs: number = 15000, enabled: boolean = true) { - const [liveTrains, setLiveTrains] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - - const fetchLiveTrains = useCallback(async () => { - try { - const activeTrains = await RealtimeService.getAllActiveTrains(); - - const trains: LiveTrain[] = activeTrains.map(({ trainNumber, position }) => { - const { routeName } = getTrainDisplayName(position.trip_id, trainNumber); - return { - trainNumber, - tripId: position.trip_id, - position: { - lat: position.latitude, - lon: position.longitude, - bearing: position.bearing, - speed: position.speed, - }, - routeName, - timestamp: position.timestamp, - }; +export function useLiveTrains(_intervalMs: number = 15000, enabled: boolean = true) { + const positions = useRealtimePositions(); + + const liveTrains = useMemo(() => { + if (!enabled) return []; + const out: LiveTrain[] = []; + for (const p of positions) { + if (p.lat == null || p.lon == null) continue; + const { routeName } = getTrainDisplayName(p.tripId, p.trainNumber, p.routeId); + const ts = Date.parse(p.lastUpdated); + out.push({ + trainNumber: p.trainNumber, + tripId: p.tripId, + position: { + lat: p.lat, + lon: p.lon, + bearing: p.heading ?? undefined, + speed: p.speedMph ?? undefined, + }, + routeName, + timestamp: Number.isFinite(ts) ? ts : Date.now(), }); - - setLiveTrains(trains); - setLastUpdated(Date.now()); - setError(null); - logger.debug(`[LiveTrains] Updated ${trains.length} active trains`); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch live trains')); - logger.error('Error fetching live trains:', err); - } finally { - setLoading(false); } - }, []); - - // Initial fetch + periodic refresh (single effect) - useEffect(() => { - if (!enabled) return; - fetchLiveTrains(); - const interval = setInterval(fetchLiveTrains, intervalMs); - return () => clearInterval(interval); - }, [fetchLiveTrains, intervalMs, enabled]); - - // Manual refresh function - const refresh = useCallback(() => { - RealtimeService.clearCache(); - return fetchLiveTrains(); - }, [fetchLiveTrains]); + return out; + }, [positions, enabled]); return { liveTrains, - loading, - error, - lastUpdated, - refresh, + loading: liveTrains.length === 0, + error: null as Error | null, + lastUpdated: liveTrains.length > 0 ? Date.now() : null, + refresh: () => Promise.resolve(), trainCount: liveTrains.length, }; } diff --git a/apps/mobile/hooks/useRealtime.ts b/apps/mobile/hooks/useRealtime.ts index b53351e..58cac87 100644 --- a/apps/mobile/hooks/useRealtime.ts +++ b/apps/mobile/hooks/useRealtime.ts @@ -1,10 +1,16 @@ +/** + * Realtime enrichment for saved trains. Subscribes to the WebSocket via + * RealtimeContext and re-attaches latest position data to each saved train + * whenever the feed updates. No more polling. + */ + import { useEffect, useRef } from 'react'; +import { useRealtimePositions } from '../context/RealtimeContext'; import { TrainAPIService } from '../services/api'; import { TrainActivityManager } from '../services/train-activity-manager'; import type { Train } from '../types/train'; import { logger } from '../utils/logger'; -/** Compare realtime-changing fields to detect if a train actually changed */ function hasRealtimeChanged(a: Train, b: Train): boolean { const ar = a.realtime; const br = b.realtime; @@ -20,36 +26,41 @@ function hasRealtimeChanged(a: Train, b: Train): boolean { ); } -export function useRealtime(trains: Train[], setTrains: (t: Train[]) => void, intervalMs: number = 20000) { - // Use ref to avoid resetting interval when trains change +/** + * @param trains - current saved trains + * @param setTrains - state setter for the saved trains list + * @param _intervalMs - retained for backwards compat; no-op now (WS-driven). + */ +export function useRealtime( + trains: Train[], + setTrains: (t: Train[]) => void, + _intervalMs: number = 20000, +) { + const positions = useRealtimePositions(); const trainsRef = useRef(trains); trainsRef.current = trains; - const setTrainsRef = useRef(setTrains); setTrainsRef.current = setTrains; useEffect(() => { - let mounted = true; + const current = trainsRef.current; + if (current.length === 0) return; + let cancelled = false; - const refresh = async () => { - if (trainsRef.current.length === 0) return; - logger.debug(`[Realtime] Refreshing ${trainsRef.current.length} saved trains`); - const oldTrains = trainsRef.current; - const updated = await Promise.all(trainsRef.current.map(t => TrainAPIService.refreshRealtimeData(t))); - if (mounted) { - // Only trigger re-render if any train's realtime data actually changed - const anyChanged = updated.some((t, i) => hasRealtimeChanged(t, oldTrains[i])); - if (anyChanged) { - setTrainsRef.current(updated); - } - TrainActivityManager.onRealtimeUpdate(oldTrains, updated).catch(e => logger.warn('TrainActivityManager.onRealtimeUpdate failed', e)); + (async () => { + const updated = await Promise.all(current.map(t => TrainAPIService.refreshRealtimeData(t))); + if (cancelled) return; + const anyChanged = updated.some((t, i) => hasRealtimeChanged(t, current[i])); + if (anyChanged) { + setTrainsRef.current(updated); } - }; + TrainActivityManager.onRealtimeUpdate(current, updated).catch(e => + logger.warn('TrainActivityManager.onRealtimeUpdate failed', e), + ); + })(); - const timer = setInterval(refresh, intervalMs); return () => { - mounted = false; - clearInterval(timer); + cancelled = true; }; - }, [intervalMs]); // Only depend on intervalMs, not trains + }, [positions]); } diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index a6326ad..7b60441 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -30,8 +30,8 @@ import { } from '../constants/map'; import { type ColorPalette, withTextShadow } from '../constants/theme'; import { useColors, useTheme } from '../context/ThemeContext'; -import { GTFSRefreshProvider, useGTFSRefresh } from '../context/GTFSRefreshContext'; import { ModalProvider, useModalActions, useModalState } from '../context/ModalContext'; +import { RealtimeProvider } from '../context/RealtimeContext'; import { TrainProvider, useTrainContext } from '../context/TrainContext'; import { UnitsProvider } from '../context/UnitsContext'; import { useLiveTrains } from '../hooks/useLiveTrains'; @@ -139,7 +139,6 @@ function MapScreenInner() { const styles = useMemo(() => createStyles(colors), [colors]); const cameraRef = useRef(null); const modalContentRef = useRef(null); - const { triggerRefresh, isLoadingCache } = useGTFSRefresh(); // Split modal context — actions (stable) vs state (reactive) const { @@ -547,10 +546,11 @@ function MapScreenInner() { duration: MAP_ANIMATION_DURATION, }); - // Create a Stop object and navigate + // Synthesize a Stop and navigate. The DepartureBoardModal will fetch + // full detail from the API via the stationCode/lat/lon we already have. const stop: Stop = { stop_id: stationCode, - stop_name: gtfsParser.getStopName(stationCode), + stop_name: stationCode, stop_lat: lat, stop_lon: lon, }; @@ -564,28 +564,15 @@ function MapScreenInner() { requestNotificationPermissions(); }, []); - // Track when GTFS data is loaded — event-based, no polling - const [gtfsLoaded, setGtfsLoaded] = React.useState(gtfsParser.isLoaded); - + // Load saved trains on mount (was gated on GTFS cache load — now API-backed) React.useEffect(() => { - if (gtfsLoaded) return; - return gtfsParser.onLoaded(() => { - logger.info('[MapScreen] GTFS data ready'); - setGtfsLoaded(true); - }); - }, [gtfsLoaded]); - - // Load saved trains after GTFS is ready - React.useEffect(() => { - if (!gtfsLoaded) return; - (async () => { const trains = await TrainStorageService.getSavedTrains(); logger.debug(`[MapScreen] Loading ${trains.length} saved trains with realtime data`); const trainsWithRealtime = await Promise.all(trains.map(train => TrainAPIService.refreshRealtimeData(train))); setSavedTrains(trainsWithRealtime); })(); - }, [setSavedTrains, gtfsLoaded]); + }, [setSavedTrains]); useRealtime(savedTrains, setSavedTrains, 20000); @@ -931,18 +918,10 @@ function MapScreenInner() { > {showSettingsContent && ( goBack()}> - goBack()} - onRefreshGTFS={() => { - triggerRefresh(); - }} - /> + goBack()} onRefreshGTFS={() => {}} /> )} - - {/* Full-page loading overlay while GTFS cache loads */} - ); } @@ -950,15 +929,15 @@ function MapScreenInner() { export default function MapScreen() { return ( - - + + - - + + ); } diff --git a/apps/mobile/services/api-client.ts b/apps/mobile/services/api-client.ts index b31886a..9e7935b 100644 --- a/apps/mobile/services/api-client.ts +++ b/apps/mobile/services/api-client.ts @@ -272,3 +272,29 @@ export function getRunPosition(_params: { export function clearApiCache(): void { cache.clear(); } + +/** + * Synchronously read a previously-fetched route from the in-memory cache. + * Returns undefined if the route hasn't been requested via getRoute() yet + * or its TTL has expired. Useful for render-time lookups where firing an + * async fetch would cause a flicker. + */ +export function getCachedRoute(routeId: string): ApiRoute | undefined { + return getCached(`route:${routeId}`); +} + +/** + * Fire-and-forget prefetch of route metadata. Safe to call repeatedly; the + * result lands in the same cache that getCachedRoute reads from. + */ +export function prefetchRoute(routeId: string): void { + if (getCached(`route:${routeId}`) !== undefined) return; + const sep = routeId.indexOf(':'); + if (sep <= 0) return; + const provider = routeId.slice(0, sep); + const code = routeId.slice(sep + 1); + // Swallow errors — prefetching is best-effort. + getRoute(provider, code).catch(() => { + /* leave it un-cached so a later attempt can retry */ + }); +} diff --git a/apps/mobile/services/api.ts b/apps/mobile/services/api.ts index cbbbdc8..2fb59b0 100644 --- a/apps/mobile/services/api.ts +++ b/apps/mobile/services/api.ts @@ -1,11 +1,31 @@ /** - * API service for fetching train data - * Provides abstraction layer for GTFS data access and future real-time API integration + * Train data access layer. Backend-API-backed; thin adapter from the + * spec.* response shapes (types/api.ts) to the consumer-facing Train / + * Stop / EnrichedStopTime shapes (types/train.ts). + * + * Realtime data is read non-reactively from the WebSocket client's cached + * snapshot — the source of truth for the WS feed lives in services/ws-client. */ -import type { EnrichedStopTime, Route, SearchResult, Stop, Train } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { RealtimeService } from './realtime'; +import type { EnrichedStopTime, Stop, Train } from '../types/train'; +import type { + ApiDepartureItem, + ApiEnrichedStopTime, + ApiStop, + ApiTrainPosition, + ApiTrip, +} from '../types/api'; +import { + ApiError, + getCachedRoute, + getDepartures, + getStop, + getTrip, + getTripStops, + lookupTrips, + prefetchRoute, +} from './api-client'; +import { wsClient } from './ws-client'; import { formatTime, formatTimeWithDayOffset, type FormattedTime } from '../utils/time-formatting'; import { convertGtfsTimeForStop } from '../utils/timezone'; import { extractDateFromTripId, extractTrainNumber, isLikelyTrainNumber } from '../utils/train-helpers'; @@ -16,7 +36,10 @@ import { logger } from '../utils/logger'; export { formatTime, formatTimeWithDayOffset, extractTrainNumber, isLikelyTrainNumber }; export type { FormattedTime }; -/** Deterministic numeric hash from a string (avoids Date.now() collisions) */ +// Until multi-provider support lands, all legacy call sites assume Amtrak. +const DEFAULT_PROVIDER = 'amtrak'; + +/** Deterministic numeric hash from a string (avoids Date.now() collisions). */ function simpleHash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -25,466 +48,231 @@ function simpleHash(str: string): number { return Math.abs(hash); } -/** - * Amtrak train number to route name mapping - * Common named trains and their number ranges - */ -const AMTRAK_ROUTE_NAMES: Record = { - // Acela is intentionally omitted — resolved dynamically via GTFS route_long_name - // Long-distance trains - '1': 'Sunset Limited', - '2': 'Sunset Limited', - '3': 'Southwest Chief', - '4': 'Southwest Chief', - '5': 'California Zephyr', - '6': 'California Zephyr', - '7': 'Empire Builder', - '8': 'Empire Builder', - '27': 'Empire Builder', - '28': 'Empire Builder', - '11': 'Coast Starlight', - '14': 'Coast Starlight', - '19': 'Crescent', - '20': 'Crescent', - '21': 'Texas Eagle', - '22': 'Texas Eagle', - '421': 'Texas Eagle', - '422': 'Texas Eagle', - '29': 'Capitol Limited', - '30': 'Capitol Limited', - '48': 'Lake Shore Limited', - '49': 'Lake Shore Limited', - '448': 'Lake Shore Limited', - '449': 'Lake Shore Limited', - '50': 'Cardinal', - '51': 'Cardinal', - '52': 'Auto Train', - '53': 'Auto Train', - '58': 'City of New Orleans', - '59': 'City of New Orleans', - '66': 'Palmetto', - '67': 'Northeast Regional', - '79': 'Carolinian', - '80': 'Carolinian', - '89': 'Palmetto', - '90': 'Palmetto', - '91': 'Silver Star', - '92': 'Silver Star', - '97': 'Silver Meteor', - '98': 'Silver Meteor', - // Keystone/Pennsylvanian - '42': 'Pennsylvanian', - '43': 'Pennsylvanian', - '600': 'Keystone', - '601': 'Keystone', - '602': 'Keystone', - '603': 'Keystone', - '604': 'Keystone', - '605': 'Keystone', - '606': 'Keystone', - '607': 'Keystone', - '608': 'Keystone', - '609': 'Keystone', - '610': 'Keystone', - '611': 'Keystone', - '612': 'Keystone', - '613': 'Keystone', - '614': 'Keystone', - '615': 'Keystone', - '616': 'Keystone', - '617': 'Keystone', - '618': 'Keystone', - '619': 'Keystone', - '620': 'Keystone', - '621': 'Keystone', - '622': 'Keystone', - '623': 'Keystone', - '624': 'Keystone', - '625': 'Keystone', - '626': 'Keystone', - '627': 'Keystone', - '628': 'Keystone', - '629': 'Keystone', - '630': 'Keystone', - '631': 'Keystone', - '640': 'Keystone', - '641': 'Keystone', - '642': 'Keystone', - '643': 'Keystone', - '644': 'Keystone', - '645': 'Keystone', - '646': 'Keystone', - '647': 'Keystone', - '648': 'Keystone', - '649': 'Keystone', - '650': 'Keystone', - '651': 'Keystone', - '660': 'Keystone', - '661': 'Keystone', - '662': 'Keystone', - '663': 'Keystone', - // Pacific Surfliner - '761': 'Pacific Surfliner', - '762': 'Pacific Surfliner', - '763': 'Pacific Surfliner', - '764': 'Pacific Surfliner', - '765': 'Pacific Surfliner', - '766': 'Pacific Surfliner', - '767': 'Pacific Surfliner', - '768': 'Pacific Surfliner', - '769': 'Pacific Surfliner', - '770': 'Pacific Surfliner', - '771': 'Pacific Surfliner', - '772': 'Pacific Surfliner', - '773': 'Pacific Surfliner', - '774': 'Pacific Surfliner', - '775': 'Pacific Surfliner', - '776': 'Pacific Surfliner', - '777': 'Pacific Surfliner', - '778': 'Pacific Surfliner', - '779': 'Pacific Surfliner', - '780': 'Pacific Surfliner', - '781': 'Pacific Surfliner', - '782': 'Pacific Surfliner', - '783': 'Pacific Surfliner', - '784': 'Pacific Surfliner', - '785': 'Pacific Surfliner', - '786': 'Pacific Surfliner', - '787': 'Pacific Surfliner', - '788': 'Pacific Surfliner', - '789': 'Pacific Surfliner', - '790': 'Pacific Surfliner', - '791': 'Pacific Surfliner', - '792': 'Pacific Surfliner', - '793': 'Pacific Surfliner', - '794': 'Pacific Surfliner', - '795': 'Pacific Surfliner', - '796': 'Pacific Surfliner', - // Cascades - '500': 'Cascades', - '501': 'Cascades', - '502': 'Cascades', - '503': 'Cascades', - '504': 'Cascades', - '505': 'Cascades', - '506': 'Cascades', - '507': 'Cascades', - '508': 'Cascades', - '509': 'Cascades', - '510': 'Cascades', - '511': 'Cascades', - '512': 'Cascades', - '513': 'Cascades', - '514': 'Cascades', - '515': 'Cascades', - '516': 'Cascades', - '517': 'Cascades', - '518': 'Cascades', - '519': 'Cascades', - // Hiawatha - '329': 'Hiawatha', - '330': 'Hiawatha', - '331': 'Hiawatha', - '332': 'Hiawatha', - '333': 'Hiawatha', - '334': 'Hiawatha', - '335': 'Hiawatha', - '336': 'Hiawatha', - '337': 'Hiawatha', - '338': 'Hiawatha', - '339': 'Hiawatha', - '340': 'Hiawatha', - '341': 'Hiawatha', - '342': 'Hiawatha', - '343': 'Hiawatha', - '344': 'Hiawatha', - // San Joaquins - '701': 'San Joaquins', - '702': 'San Joaquins', - '703': 'San Joaquins', - '704': 'San Joaquins', - '705': 'San Joaquins', - '706': 'San Joaquins', - '707': 'San Joaquins', - '708': 'San Joaquins', - '709': 'San Joaquins', - '710': 'San Joaquins', - '711': 'San Joaquins', - '712': 'San Joaquins', - '713': 'San Joaquins', - '714': 'San Joaquins', - '715': 'San Joaquins', - '716': 'San Joaquins', - '717': 'San Joaquins', - '718': 'San Joaquins', - '719': 'San Joaquins', - '720': 'San Joaquins', - // Capitol Corridor - '521': 'Capitol Corridor', - '522': 'Capitol Corridor', - '523': 'Capitol Corridor', - '524': 'Capitol Corridor', - '525': 'Capitol Corridor', - '526': 'Capitol Corridor', - '527': 'Capitol Corridor', - '528': 'Capitol Corridor', - '529': 'Capitol Corridor', - '530': 'Capitol Corridor', - '531': 'Capitol Corridor', - '532': 'Capitol Corridor', - '533': 'Capitol Corridor', - '534': 'Capitol Corridor', - '535': 'Capitol Corridor', - '536': 'Capitol Corridor', - '537': 'Capitol Corridor', - '538': 'Capitol Corridor', - '539': 'Capitol Corridor', - '540': 'Capitol Corridor', - '541': 'Capitol Corridor', - '542': 'Capitol Corridor', - '543': 'Capitol Corridor', - '544': 'Capitol Corridor', - '545': 'Capitol Corridor', - '546': 'Capitol Corridor', - '547': 'Capitol Corridor', - '548': 'Capitol Corridor', - '549': 'Capitol Corridor', - '550': 'Capitol Corridor', - '551': 'Capitol Corridor', - '552': 'Capitol Corridor', - // Vermonter - '54': 'Vermonter', - '55': 'Vermonter', - '56': 'Vermonter', - '57': 'Vermonter', - // Ethan Allen Express - '290': 'Ethan Allen Express', - '291': 'Ethan Allen Express', - '292': 'Ethan Allen Express', - '293': 'Ethan Allen Express', - // Downeaster - '680': 'Downeaster', - '681': 'Downeaster', - '682': 'Downeaster', - '683': 'Downeaster', - '684': 'Downeaster', - '685': 'Downeaster', - '686': 'Downeaster', - '687': 'Downeaster', - '688': 'Downeaster', - '689': 'Downeaster', - '690': 'Downeaster', - '691': 'Downeaster', - '692': 'Downeaster', - '693': 'Downeaster', - '694': 'Downeaster', - '695': 'Downeaster', - // Adirondack - '68': 'Adirondack', - '69': 'Adirondack', - // Maple Leaf - '63': 'Maple Leaf', - '64': 'Maple Leaf', - // Wolverines - '350': 'Wolverine', - '351': 'Wolverine', - '352': 'Wolverine', - '353': 'Wolverine', - '354': 'Wolverine', - '355': 'Wolverine', - // 364/365 are shared between Wolverine and Blue Water depending on the day - '364': 'Wolverine / Blue Water', - '365': 'Wolverine / Blue Water', - // Pere Marquette - '370': 'Pere Marquette', - '371': 'Pere Marquette', - // Illini/Saluki - '390': 'Saluki', - '391': 'Saluki', - '392': 'Illini', - '393': 'Illini', - // Lincoln Service - '300': 'Lincoln Service', - '301': 'Lincoln Service', - '302': 'Lincoln Service', - '303': 'Lincoln Service', - '304': 'Lincoln Service', - '305': 'Lincoln Service', - '306': 'Lincoln Service', - '307': 'Lincoln Service', - '308': 'Lincoln Service', - '309': 'Lincoln Service', - '310': 'Lincoln Service', - // 311/313/314 are shared between Lincoln Service and Missouri River Runner - '311': 'Lincoln Service / Missouri River Runner', - '312': 'Lincoln Service', - '313': 'Lincoln Service / Missouri River Runner', - '314': 'Lincoln Service / Missouri River Runner', - '315': 'Lincoln Service', - '316': 'Missouri River Runner', - // Heartland Flyer - '821': 'Heartland Flyer', - '822': 'Heartland Flyer', -}; +function isNamespaced(id: string): boolean { + return id.includes(':'); +} -/** - * Get the route name for a train number - * Returns the named route (e.g., "Pennsylvanian") or null if not a named train - */ -function getRouteNameForTrainNumber(trainNumber: string): string | null { - return AMTRAK_ROUTE_NAMES[trainNumber] || null; +function toYMD(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function isoToEpochMs(s: string | null | undefined): number | undefined { + if (!s) return undefined; + const t = Date.parse(s); + return Number.isFinite(t) ? t : undefined; +} + +function safeAwait(p: Promise, fallback: T): Promise { + return p.catch((err: unknown) => { + if (!(err instanceof ApiError && err.status === 404)) { + logger.warn(`[api] request failed`, err); + } + return fallback; + }); +} + +// ── Adapters: api types → existing types ────────────────────────────────── + +function adaptStop(s: ApiStop): Stop { + return { + stop_id: s.code, + stop_name: s.name, + stop_lat: s.lat, + stop_lon: s.lon, + stop_timezone: s.timezone ?? undefined, + }; +} + +function adaptStopTime(et: ApiEnrichedStopTime): EnrichedStopTime { + return { + trip_id: et.tripId, + arrival_time: et.arrivalTime ?? '', + departure_time: et.departureTime ?? et.arrivalTime ?? '', + stop_id: et.stopCode, + stop_sequence: et.stopSequence, + pickup_type: et.pickupType ?? undefined, + drop_off_type: et.dropOffType ?? undefined, + timepoint: et.timepoint == null ? undefined : et.timepoint ? 1 : 0, + stop_name: et.stopName, + stop_code: et.stopCode, + }; } +// ── Display name helpers ─────────────────────────────────────────────────── + /** - * Get display info for a train (route name and number formatted for display) - * Examples: "Pennsylvanian 43", "Acela 2151", "Amtrak 171" - * @param knownTrainNumber - Optional pre-resolved train number (e.g. from vehicle.id) + * Display info for a train. Reads from the in-memory route cache; if the + * routeId hasn't been fetched yet, kicks off a background fetch and falls + * back to a short label so the UI doesn't show empty space. */ export function getTrainDisplayName( - tripId: string, + tripIdOrTrainNumber: string, knownTrainNumber?: string, + knownRouteId?: string, ): { routeName: string | null; trainNumber: string; displayName: string; } { - const trainNumber = knownTrainNumber || extractTrainNumber(tripId) || ''; - - // First try the hardcoded mapping (covers named trains with friendly names) - let routeName = trainNumber ? getRouteNameForTrainNumber(trainNumber) : null; - - // If not in mapping, try to get from GTFS route data - if (!routeName) { - const routeId = gtfsParser.getRouteIdForTrip(tripId); - if (routeId) { - const route = gtfsParser.getRoute(routeId); - if (route?.route_long_name && route.route_long_name !== 'Unknown Route') { - routeName = route.route_long_name; - } - } + const trainNumber = + knownTrainNumber || extractTrainNumber(tripIdOrTrainNumber) || ''; + + let routeName: string | null = null; + if (knownRouteId) { + prefetchRoute(knownRouteId); + routeName = getCachedRoute(knownRouteId)?.longName ?? null; } const displayName = routeName ? `${routeName}${trainNumber ? ' ' + trainNumber : ''}` - : trainNumber ? `Amtrak ${trainNumber}` : 'Amtrak'; + : trainNumber + ? `Amtrak ${trainNumber}` + : 'Amtrak'; return { routeName, trainNumber, displayName }; } -export class TrainAPIService { - /** - * Search for trains, routes, and stations - */ - static async search(query: string): Promise { - try { - // In a real app, this would be an API call - // For now, use the local GTFS parser - return gtfsParser.search(query); - } catch (error) { - logger.error('Error searching:', error); - return []; - } +// ── Realtime helpers (read-only snapshot from ws-client) ─────────────────── + +function findPositionForTrain(args: { + tripId?: string; + trainNumber?: string; +}): ApiTrainPosition | undefined { + return wsClient.findPosition({ + provider: DEFAULT_PROVIDER, + tripId: args.tripId, + trainNumber: args.trainNumber, + }); +} + +function attachRealtime(train: Train): Train { + const pos = findPositionForTrain({ + tripId: train.tripId, + trainNumber: train.trainNumber, + }); + if (!pos) { + return { ...train, realtime: undefined }; } + return { + ...train, + realtime: { + position: + pos.lat != null && pos.lon != null ? { lat: pos.lat, lon: pos.lon } : undefined, + // delay/arrivalDelay are populated by /v1/runs/{...}/stops which isn't + // wired yet. Until then we leave them undefined; status text is computed + // from delay so it's also blank. + lastUpdated: isoToEpochMs(pos.lastUpdated), + }, + }; +} - /** - * Get all available routes - */ - static async getRoutes(): Promise { - try { - return gtfsParser.getAllRoutes(); - } catch (error) { - logger.error('Error fetching routes:', error); - return []; - } +// ── Trip → Train conversion ──────────────────────────────────────────────── + +async function buildTrainFromTrip( + trip: ApiTrip, + stops: EnrichedStopTime[], + effectiveDate?: Date, +): Promise { + if (stops.length === 0) { + logger.debug(`[api] buildTrainFromTrip(${trip.tripId}): no stop times`); + return null; } - /** - * Get all available stops/stations - */ - static async getStops(): Promise { - try { - return gtfsParser.getAllStops(); - } catch (error) { - logger.error('Error fetching stops:', error); - return []; - } + const firstStop = stops[0]; + const lastStop = stops[stops.length - 1]; + + // Warm route cache so display name resolves once it lands + prefetchRoute(trip.routeId); + const route = getCachedRoute(trip.routeId); + const routeName = route?.longName || route?.shortName || ''; + + const departFormatted = convertGtfsTimeForStop(firstStop.departure_time, firstStop.stop_id); + const arriveFormatted = convertGtfsTimeForStop(lastStop.arrival_time, lastStop.stop_id); + + const train: Train = { + id: simpleHash(trip.tripId), + operator: 'Amtrak', + trainNumber: trip.shortName || extractTrainNumber(trip.tripId) || '', + from: firstStop.stop_name, + to: lastStop.stop_name, + fromCode: firstStop.stop_id, + toCode: lastStop.stop_id, + departTime: departFormatted.time, + arriveTime: arriveFormatted.time, + departDayOffset: departFormatted.dayOffset, + arriveDayOffset: arriveFormatted.dayOffset, + date: effectiveDate ? getDaysAwayLabel(calculateDaysAway(effectiveDate)) : 'Today', + daysAway: effectiveDate ? calculateDaysAway(effectiveDate) : 0, + travelDate: effectiveDate ? effectiveDate.getTime() : undefined, + routeName, + tripId: trip.tripId, + intermediateStops: stops.slice(1, -1).map(stop => { + const formatted = convertGtfsTimeForStop(stop.departure_time, stop.stop_id); + return { + time: formatted.time, + name: stop.stop_name, + code: stop.stop_id, + }; + }), + }; + + if (train.daysAway <= 0) { + return attachRealtime(train); } + return train; +} + +// ── Public API ───────────────────────────────────────────────────────────── +export class TrainAPIService { /** - * Get train details for a specific trip + * Get train details for a specific trip. tripId may be: + * - the namespaced API id (e.g. "amtrak:5_2026-05-08") + * - a bare train number (e.g. "5") — resolved via lookupTrips */ - static async getTrainDetails(tripId: string, date?: Date, knownTrainNumber?: string): Promise { + static async getTrainDetails( + tripId: string, + date?: Date, + knownTrainNumber?: string, + ): Promise { try { - let stopTimes = gtfsParser.getStopTimesForTrip(tripId); - let resolvedTripId = tripId; + let trip: ApiTrip | null = null; + + if (isNamespaced(tripId)) { + trip = await safeAwait(getTrip(tripId), null); + } - // If direct lookup failed, resolve via train number + date - // This handles GTFS-RT trip_ids that differ from static GTFS trip_ids - if (stopTimes.length === 0) { + if (!trip) { const trainNumber = knownTrainNumber || extractTrainNumber(tripId); if (!trainNumber) { - logger.debug(`[API] getTrainDetails(${tripId}): cannot extract train number`); + logger.debug(`[api] getTrainDetails(${tripId}): cannot extract train number`); return null; } const inferredDate = date ?? extractDateFromTripId(tripId) ?? new Date(); - const trip = gtfsParser.getTripForTrainOnDate(trainNumber, inferredDate); - if (trip) { - resolvedTripId = trip.trip_id; - stopTimes = gtfsParser.getStopTimesForTrip(resolvedTripId); - } + const trips = await safeAwait( + lookupTrips({ + provider: DEFAULT_PROVIDER, + trainNumber, + date: toYMD(inferredDate), + }), + [], + ); + trip = trips[0] ?? null; } - if (stopTimes.length === 0) { - logger.debug(`[API] getTrainDetails(${tripId}): no stop times found`); + if (!trip) { + logger.debug(`[api] getTrainDetails(${tripId}): no matching trip`); return null; } - const firstStop = stopTimes[0]; - const lastStop = stopTimes[stopTimes.length - 1]; - - // Get proper train number and route name - const { routeName, trainNumber } = getTrainDisplayName(resolvedTripId, knownTrainNumber || undefined); - - // Format times with day offset info, converting to each stop's local timezone - const departFormatted = convertGtfsTimeForStop(firstStop.departure_time, firstStop.stop_id); - const arriveFormatted = convertGtfsTimeForStop(lastStop.arrival_time, lastStop.stop_id); - - // Infer departure date from trip ID when no explicit date provided - const effectiveDate = date ?? extractDateFromTripId(resolvedTripId) ?? undefined; - - const train: Train = { - id: simpleHash(resolvedTripId), - operator: 'Amtrak', - trainNumber: trainNumber, - from: firstStop.stop_name, - to: lastStop.stop_name, - fromCode: firstStop.stop_id, - toCode: lastStop.stop_id, - departTime: departFormatted.time, - arriveTime: arriveFormatted.time, - departDayOffset: departFormatted.dayOffset, - arriveDayOffset: arriveFormatted.dayOffset, - date: effectiveDate ? getDaysAwayLabel(calculateDaysAway(effectiveDate)) : 'Today', - daysAway: effectiveDate ? calculateDaysAway(effectiveDate) : 0, - travelDate: effectiveDate ? effectiveDate.getTime() : undefined, - routeName: routeName || '', - tripId: resolvedTripId, - intermediateStops: stopTimes.slice(1, -1).map(stop => { - const formatted = convertGtfsTimeForStop(stop.departure_time, stop.stop_id); - return { - time: formatted.time, - name: stop.stop_name, - code: stop.stop_id, - }; - }), - }; - - // Fetch real-time data only for today's trains - if (train.daysAway <= 0) { - await this.enrichWithRealtimeData(train); - } + const apiStops = await safeAwait(getTripStops(trip.tripId), []); + const stops = apiStops.map(adaptStopTime); - return train; + const effectiveDate = date ?? extractDateFromTripId(trip.tripId) ?? undefined; + return buildTrainFromTrip(trip, stops, effectiveDate); } catch (error) { logger.error('Error fetching train details:', error); return null; @@ -492,72 +280,97 @@ export class TrainAPIService { } /** - * Enrich a train object with real-time position and delay data - */ - private static async enrichWithRealtimeData(train: Train): Promise { - try { - const tripKey = train.trainNumber || train.tripId || ''; - const [position, delay, arrivalDelay] = await Promise.all([ - RealtimeService.getPositionForTrip(tripKey), - RealtimeService.getDelayForStop(tripKey, train.fromCode), - RealtimeService.getArrivalDelayForStop(tripKey, train.toCode), - ]); - - train.realtime = { - position: position ? { lat: position.latitude, lon: position.longitude } : undefined, - delay: delay ?? undefined, - arrivalDelay: arrivalDelay ?? undefined, - status: RealtimeService.formatDelay(delay), - lastUpdated: position?.timestamp, - }; - } catch (realtimeError) { - logger.warn('Could not fetch real-time data:', realtimeError); - } - } - - /** - * Get trains for a specific station + * All trains arriving/departing at a stop on a given date. + * + * Currently issues N+1 requests (one getTripStops per departure) since + * /v1/departures only returns a Trip-level row. The 1h trip-stops cache + * makes repeats cheap, but cold-start for a busy station is slower than + * the old in-memory parser. */ static async getTrainsForStation(stopId: string, date?: Date): Promise { try { - const tripIds = gtfsParser.getTripsForStop(stopId, date); - logger.debug(`[API] getTrainsForStation(${stopId}): ${tripIds.length} trip IDs`); - const trains = await Promise.all(tripIds.map(tripId => this.getTrainDetails(tripId, date))); - return trains.filter((train): train is Train => train !== null); + const effectiveDate = date ?? new Date(); + const provider = stopId.includes(':') ? stopId.split(':', 1)[0] : DEFAULT_PROVIDER; + const namespaced = stopId.includes(':') ? stopId : `${provider}:${stopId}`; + + const departures = await safeAwait( + getDepartures({ stopId: namespaced, date: toYMD(effectiveDate) }), + [], + ); + logger.debug(`[api] getTrainsForStation(${stopId}): ${departures.length} departures`); + + const trains = await Promise.all( + departures.map(async (d): Promise => { + const stops = await safeAwait( + getTripStops(d.tripId), + [], + ); + // ApiDepartureItem extends Trip (same shape), so we can pass it through. + const trip: ApiTrip = { + providerId: d.providerId, + tripId: d.tripId, + routeId: d.routeId, + serviceId: d.serviceId, + shortName: d.shortName, + headsign: d.headsign, + shapeId: d.shapeId, + directionId: d.directionId, + }; + return buildTrainFromTrip(trip, stops.map(adaptStopTime), effectiveDate); + }), + ); + return trains.filter((t): t is Train => t !== null); } catch (error) { logger.error('Error fetching trains for station:', error); return []; } } - /** - * Get stop times for a specific trip - */ + /** Stop times for a trip — used by storage rehydration of segmented trips. */ static async getStopTimesForTrip(tripId: string): Promise { try { - return gtfsParser.getStopTimesForTrip(tripId); + const apiStops = await getTripStops(tripId); + return apiStops.map(adaptStopTime); } catch (error) { logger.error('Error fetching stop times:', error); return []; } } + /** Look up a stop by its raw code (assumes Amtrak until multi-provider). */ + static async getStop(stopId: string): Promise { + try { + const code = stopId.includes(':') ? stopId.split(':')[1] : stopId; + const provider = stopId.includes(':') ? stopId.split(':')[0] : DEFAULT_PROVIDER; + const apiStop = await getStop(provider, code); + return adaptStop(apiStop); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null; + logger.error('Error fetching stop:', error); + return null; + } + } + /** - * Refresh real-time data for a train - * Skips realtime enrichment for future trains (daysAway > 0) to avoid - * matching a saved future train to today's live train with the same number + * Re-attach realtime snapshot data to a saved train. The new flow doesn't + * poll — the WebSocket pushes — so this is essentially a sync read. + * Future trains skip realtime to avoid matching today's same-numbered run. */ static async refreshRealtimeData(train: Train): Promise { if (!train.tripId && !train.trainNumber) return train; - - // Don't fetch realtime data for trains not running today - if (train.daysAway > 0) { - return { ...train, realtime: undefined }; - } - - const updatedTrain = { ...train }; - await this.enrichWithRealtimeData(updatedTrain); - return updatedTrain; + if (train.daysAway > 0) return { ...train, realtime: undefined }; + return attachRealtime(train); } - } + +// Backwards-compat shim — getRoutes/getStops aren't exposed by the API. +// useFrequentlyUsed should be reworked to use search; until then, this file's +// callers will see an empty list and a warning. +export const _legacyAPIWarning = () => { + logger.warn( + '[api] TrainAPIService.getRoutes/getStops are removed; use api-client search/lookups instead', + ); +}; + +// formatDateForDisplay is re-exported for callers that imported it transitively. +export { formatDateForDisplay }; diff --git a/apps/mobile/services/ws-client.ts b/apps/mobile/services/ws-client.ts index 8a5cf3b..ddfc4e8 100644 --- a/apps/mobile/services/ws-client.ts +++ b/apps/mobile/services/ws-client.ts @@ -10,7 +10,7 @@ */ import { config } from '../constants/config'; -import type { RealtimeUpdate } from '../types/api'; +import type { ApiTrainPosition, RealtimeUpdate } from '../types/api'; import { logger } from '../utils/logger'; type Listener = (update: RealtimeUpdate) => void; @@ -34,6 +34,9 @@ class WSClient { private reconnectTimer: ReturnType | null = null; private intentionallyClosed = false; + /** Per-provider snapshot of the last positions array we received. */ + private latest = new Map(); + /** * Subscribe a listener to one or more providers. Returns an unsubscribe * function. Listeners are invoked for *every* RealtimeUpdate the socket @@ -97,6 +100,26 @@ class WSClient { this.state = 'closed'; } + /** All known live positions across providers, in arrival order. */ + getLatestPositions(): ApiTrainPosition[] { + const out: ApiTrainPosition[] = []; + for (const list of this.latest.values()) for (const p of list) out.push(p); + return out; + } + + /** Latest live position for a specific run, or undefined if not present. */ + findPosition(opts: { provider: string; tripId?: string; trainNumber?: string }): + | ApiTrainPosition + | undefined { + const list = this.latest.get(opts.provider); + if (!list) return undefined; + return list.find( + p => + (opts.tripId !== undefined && p.tripId === opts.tripId) || + (opts.trainNumber !== undefined && p.trainNumber === opts.trainNumber), + ); + } + // ── Internals ──────────────────────────────────────────────────────────── private ensureConnected(): void { @@ -139,6 +162,7 @@ class WSClient { return; } if (!isRealtimeUpdate(parsed)) return; + this.latest.set(parsed.provider, parsed.positions); for (const l of this.listeners) { try { l(parsed); From 5e70454a5ca66856637cad5087ea450ac93d3b02 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Fri, 8 May 2026 22:36:11 -0500 Subject: [PATCH 04/10] Wire location-suggestions to API; restore GTFS fallback for unmigrated consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - services/location-suggestions.ts now uses /v1/stops/nearby (stub — fails until backend lands) and /v1/departures for upcoming trains. Drops the parser argument; permissions and best-effort failure handling preserved. - screens/MapScreen restores GTFSRefreshProvider in its tree. The new API + WS paths (TrainAPIService, useLiveTrains, useRealtime, RealtimeContext) bypass the local GTFS parser, but several modal/ search consumers (TrainDetailModal, TwoStationSearch, search, calendar-sync) still use gtfsParser sync methods. Keeping the fallback loader means those consumers continue to work while migration happens piecemeal. - context/GTFSRefreshContext drops the now-unused gtfsParser import forwarded into LocationSuggestionsService. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/context/GTFSRefreshContext.tsx | 5 +- apps/mobile/screens/MapScreen.tsx | 24 ++- apps/mobile/services/location-suggestions.ts | 163 +++++++++++-------- 3 files changed, 119 insertions(+), 73 deletions(-) diff --git a/apps/mobile/context/GTFSRefreshContext.tsx b/apps/mobile/context/GTFSRefreshContext.tsx index 276c16c..c0e0e0a 100644 --- a/apps/mobile/context/GTFSRefreshContext.tsx +++ b/apps/mobile/context/GTFSRefreshContext.tsx @@ -4,7 +4,6 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useR import { Alert } from 'react-native'; import { ensureFreshGTFS, isCacheStale, loadCachedGTFS, loadDeferredShapes } from '../services/gtfs-sync'; import { LocationSuggestionsService } from '../services/location-suggestions'; -import { gtfsParser } from '../utils/gtfs-parser'; import { logger } from '../utils/logger'; interface GTFSRefreshState { @@ -67,7 +66,7 @@ export const GTFSRefreshProvider: React.FC<{ children: React.ReactNode; onRefres setRefreshStep('Refresh complete'); setRefreshFailed(false); logger.info(`[GTFS] Refresh complete (usedCache=${result.usedCache})`); - LocationSuggestionsService.initialize(gtfsParser).catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); + LocationSuggestionsService.initialize().catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); onRefreshCompleteRef.current?.(); // Brief display of completion then clear setTimeout(() => { @@ -113,7 +112,7 @@ export const GTFSRefreshProvider: React.FC<{ children: React.ReactNode; onRefres loadDeferredShapes().finally(() => setIsStreamingData(false)); // Pre-compute location-based suggestions in background - LocationSuggestionsService.initialize(gtfsParser).catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); + LocationSuggestionsService.initialize().catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); // Check staleness in background const stale = await isCacheStale(); diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index 7b60441..f968dd2 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -30,6 +30,7 @@ import { } from '../constants/map'; import { type ColorPalette, withTextShadow } from '../constants/theme'; import { useColors, useTheme } from '../context/ThemeContext'; +import { GTFSRefreshProvider, useGTFSRefresh } from '../context/GTFSRefreshContext'; import { ModalProvider, useModalActions, useModalState } from '../context/ModalContext'; import { RealtimeProvider } from '../context/RealtimeContext'; import { TrainProvider, useTrainContext } from '../context/TrainContext'; @@ -139,6 +140,7 @@ function MapScreenInner() { const styles = useMemo(() => createStyles(colors), [colors]); const cameraRef = useRef(null); const modalContentRef = useRef(null); + const { triggerRefresh, isLoadingCache } = useGTFSRefresh(); // Split modal context — actions (stable) vs state (reactive) const { @@ -918,10 +920,18 @@ function MapScreenInner() { > {showSettingsContent && ( goBack()}> - goBack()} onRefreshGTFS={() => {}} /> + goBack()} + onRefreshGTFS={() => { + triggerRefresh(); + }} + /> )} + + {/* Full-page loading overlay while local GTFS cache loads (fallback path) */} + ); } @@ -931,11 +941,13 @@ export default function MapScreen() { - - - - - + + + + + + + diff --git a/apps/mobile/services/location-suggestions.ts b/apps/mobile/services/location-suggestions.ts index 7b80337..9866ead 100644 --- a/apps/mobile/services/location-suggestions.ts +++ b/apps/mobile/services/location-suggestions.ts @@ -1,6 +1,16 @@ +/** + * Location-based suggestions for the search screen empty state. + * + * Backed by the new API client. The nearby-stops endpoint is a stub on + * apps/api today, so this returns no suggestions until the backend lands + * GET /v1/stops/nearby. Upcoming-train and routes-at-stop derivations + * fall out of /v1/departures. + */ + import * as Location from 'expo-location'; -import type { GTFSParser } from '../utils/gtfs-parser'; -import type { Route, Stop } from '../types/train'; +import { ApiError, getDepartures, getNearbyStops } from './api-client'; +import type { Stop } from '../types/train'; +import type { ApiDepartureItem, ApiStop } from '../types/api'; import { haversineDistance } from '../utils/distance'; import { logger } from '../utils/logger'; @@ -17,7 +27,47 @@ export interface LocationSuggestion { let cachedSuggestions: LocationSuggestion[] | null = null; let initialized = false; -async function initialize(parser: GTFSParser): Promise { +function adaptStop(s: ApiStop): Stop { + return { + stop_id: s.code, + stop_name: s.name, + stop_lat: s.lat, + stop_lon: s.lon, + stop_timezone: s.timezone ?? undefined, + }; +} + +function toYMD(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function pickClosest(stops: ApiStop[], lat: number, lon: number): { stop: ApiStop; distMi: number } | null { + if (stops.length === 0) return null; + let best: ApiStop | null = null; + let bestDist = Infinity; + for (const s of stops) { + const d = haversineDistance(lat, lon, s.lat, s.lon); + if (d < bestDist) { + bestDist = d; + best = s; + } + } + return best ? { stop: best, distMi: bestDist } : null; +} + +function formatTimeFromIso(iso?: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return '—'; + let h = d.getHours(); + const m = String(d.getMinutes()).padStart(2, '0'); + const ampm = h >= 12 ? 'PM' : 'AM'; + if (h === 0) h = 12; + else if (h > 12) h -= 12; + return `${h}:${m} ${ampm}`; +} + +async function initialize(): Promise { if (initialized) return; initialized = true; @@ -34,71 +84,56 @@ async function initialize(parser: GTFSParser): Promise { const { latitude, longitude } = location.coords; logger.info(`[LocationSuggestions] Location: ${latitude.toFixed(3)}, ${longitude.toFixed(3)}`); - // Find nearest station - const allStops = parser.getAllStops(); - if (allStops.length === 0) return; - - let nearestStop: Stop | null = null; - let nearestDist = Infinity; - for (const stop of allStops) { - const dist = haversineDistance(latitude, longitude, stop.stop_lat, stop.stop_lon); - if (dist < nearestDist) { - nearestDist = dist; - nearestStop = stop; + let nearby: ApiStop[] = []; + try { + nearby = await getNearbyStops({ lat: latitude, lon: longitude, radiusMeters: 80_000 }); + } catch (err) { + // The /v1/stops/nearby endpoint isn't on the backend yet; fail + // gracefully so the search screen renders without suggestions. + if (err instanceof ApiError) { + logger.warn(`[LocationSuggestions] /v1/stops/nearby unavailable (${err.status})`); + } else { + logger.warn('[LocationSuggestions] nearby stops unavailable', err); } + return; } - if (!nearestStop) return; - logger.info(`[LocationSuggestions] Nearest: ${nearestStop.stop_name} (${nearestDist.toFixed(1)} mi)`); - - const suggestions: LocationSuggestion[] = []; - - // 1. Nearest station - const distLabel = nearestDist < 1 - ? `${(nearestDist * 5280).toFixed(0)} ft away` - : `${nearestDist.toFixed(1)} mi away`; - suggestions.push({ - type: 'station', - label: nearestStop.stop_name, - subtitle: `${nearestStop.stop_id} · ${distLabel}`, - stop: nearestStop, - }); - - // 2. Up to 2 upcoming trains from nearest station - const upcoming = parser.getUpcomingTrainsFromStop(nearestStop.stop_id, 2); - for (const train of upcoming) { - const [hStr, mStr] = train.departureTime.split(':'); - let h = parseInt(hStr, 10); - const m = mStr; - const dayOffset = Math.floor(h / 24); - h = h % 24; - const ampm = h >= 12 ? 'PM' : 'AM'; - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; - const timeStr = `${h12}:${m} ${ampm}`; - - suggestions.push({ - type: 'train', - label: `${train.routeName} ${train.trainNumber}`, - subtitle: `Departs ${nearestStop.stop_id} at ${timeStr}`, - trainNumber: train.trainNumber, - displayName: `${train.routeName} ${train.trainNumber}`, - }); - } - - // 3. Up to 2 routes serving nearest station (skip routes already shown via trains) - const shownRouteIds = new Set(upcoming.map(t => t.trip.route_id)); - const routes = parser.getRoutesServingStop(nearestStop.stop_id); - let routeCount = 0; - for (const route of routes) { - if (routeCount >= 2) break; - if (shownRouteIds.has(route.route_id)) continue; - suggestions.push({ - type: 'route', - label: route.route_long_name, - subtitle: `Serves ${nearestStop.stop_id}`, - routeId: route.route_id, + const closest = pickClosest(nearby, latitude, longitude); + if (!closest) return; + const nearestStop = adaptStop(closest.stop); + const distLabel = closest.distMi < 1 + ? `${(closest.distMi * 5280).toFixed(0)} ft away` + : `${closest.distMi.toFixed(1)} mi away`; + + const suggestions: LocationSuggestion[] = [ + { + type: 'station', + label: nearestStop.stop_name, + subtitle: `${nearestStop.stop_id} · ${distLabel}`, + stop: nearestStop, + }, + ]; + + // Upcoming trains from this stop today (best-effort). + try { + const departures = await getDepartures({ + stopId: `${closest.stop.providerId}:${closest.stop.code}`, + date: toYMD(new Date()), }); - routeCount++; + const upcoming = departures + .filter((d): d is ApiDepartureItem & { departureTime: string } => Boolean(d.departureTime)) + .slice(0, 2); + for (const d of upcoming) { + suggestions.push({ + type: 'train', + label: `${d.shortName || d.tripId} ${d.headsign}`.trim(), + subtitle: `Departs ${nearestStop.stop_id} at ${formatTimeFromIso(d.departureTime)}`, + trainNumber: d.shortName, + displayName: `${d.shortName || ''} ${d.headsign}`.trim(), + }); + } + } catch (err) { + logger.warn('[LocationSuggestions] departures unavailable', err); } cachedSuggestions = suggestions; From 33cc0f94c670c1e2b4b1b4217b83e586d6b84644 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Sat, 9 May 2026 00:22:56 -0500 Subject: [PATCH 05/10] Fix station/route tap regressions on the PMTiles map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getTrainsForStation no longer drops a departure when its trip-stops fan-out request fails or returns empty. We now fall back to a minimal Train carrying the user's stop time directly from the DepartureItem, with a single-entry intermediateStops so DepartureBoardModal's per-stop time lookup still resolves. List view never silently empties on a partial backend. - Route line taps now register a handler. With no dedicated route detail UI yet, the tap fires haptic feedback and an Alert showing the long/short name and provider — enough to confirm selection. Co-Authored-By: Claude Opus 4.7 --- apps/mobile/screens/MapScreen.tsx | 18 ++++++- apps/mobile/services/api.ts | 82 +++++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index f968dd2..4f25435 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Ionicons from 'react-native-vector-icons/Ionicons'; import { ErrorBoundary } from '../components/ErrorBoundary'; import { LiveTrainMarker } from '../components/map/LiveTrainMarker'; -import { ProviderTiles, type StationTapPayload } from '../components/map/ProviderTiles'; +import { ProviderTiles, type RouteTapPayload, type StationTapPayload } from '../components/map/ProviderTiles'; import MapSettingsPill, { MapType, RouteMode, StationMode, TrainMode } from '../components/map/MapSettingsPill'; import DepartureBoardModal from '../components/ui/DepartureBoardModal'; import ProfileModal from '../components/ui/ProfileModal'; @@ -694,6 +694,21 @@ function MapScreenInner() { [handleStationPress], ); + // PMTiles route tap. No dedicated route-detail UI yet — show a brief + // confirmation so the user sees the tap was registered, with the route + // info already carried by the tile feature. + const handleProviderTileRoutePress = useCallback( + (payload: RouteTapPayload) => { + hapticLight(); + const title = payload.longName || payload.shortName || 'Route'; + const subtitle = payload.shortName && payload.longName + ? `${payload.shortName} — ${payload.providerId}` + : payload.providerId; + Alert.alert(title, subtitle); + }, + [] + ); + return ( ))} diff --git a/apps/mobile/services/api.ts b/apps/mobile/services/api.ts index 2fb59b0..47d675b 100644 --- a/apps/mobile/services/api.ts +++ b/apps/mobile/services/api.ts @@ -225,6 +225,57 @@ async function buildTrainFromTrip( return train; } +/** + * Fallback when /v1/trips/{tripId}/stops is unavailable: build a Train + * carrying just the user's stop time (from the DepartureItem row). + * + * The DepartureBoardModal looks up the time at this station via + * intermediateStops[].code; we synthesize a single intermediate so that + * lookup still succeeds. from/to are blank since we don't know the trip's + * actual origin or destination without /v1/trips/{tripId}/stops. + */ +function buildMinimalTrainFromDeparture( + d: ApiDepartureItem, + userStopCode: string, + effectiveDate: Date, +): Train { + prefetchRoute(d.routeId); + const route = getCachedRoute(d.routeId); + const routeName = route?.longName || route?.shortName || ''; + + const userTime = + d.departureTime != null + ? convertGtfsTimeForStop(d.departureTime, userStopCode) + : d.arrivalTime != null + ? convertGtfsTimeForStop(d.arrivalTime, userStopCode) + : { time: '', dayOffset: 0 }; + + const train: Train = { + id: simpleHash(d.tripId), + operator: 'Amtrak', + trainNumber: d.shortName || extractTrainNumber(d.tripId) || '', + from: '', + to: d.headsign || '', + fromCode: userStopCode, + toCode: '', + departTime: userTime.time, + arriveTime: userTime.time, + departDayOffset: userTime.dayOffset, + arriveDayOffset: userTime.dayOffset, + date: getDaysAwayLabel(calculateDaysAway(effectiveDate)), + daysAway: calculateDaysAway(effectiveDate), + travelDate: effectiveDate.getTime(), + routeName, + tripId: d.tripId, + intermediateStops: [ + { time: userTime.time, name: userStopCode, code: userStopCode }, + ], + }; + + if (train.daysAway <= 0) return attachRealtime(train); + return train; +} + // ── Public API ───────────────────────────────────────────────────────────── export class TrainAPIService { @@ -280,18 +331,22 @@ export class TrainAPIService { } /** - * All trains arriving/departing at a stop on a given date. + * All trains arriving/departing at a stop on a given date. Issues a + * fan-out fetch of /v1/trips/{tripId}/stops per departure to populate + * origin/destination/intermediate stops; if that fan-out fails for a + * particular trip, we still return a minimal Train (with the user's + * stop populated from the departure row) so the list never silently + * empties on a partial backend. * - * Currently issues N+1 requests (one getTripStops per departure) since - * /v1/departures only returns a Trip-level row. The 1h trip-stops cache - * makes repeats cheap, but cold-start for a busy station is slower than - * the old in-memory parser. + * The trip-stops endpoint is cached for 1h, so repeated views of busy + * stations are cheap. */ static async getTrainsForStation(stopId: string, date?: Date): Promise { try { const effectiveDate = date ?? new Date(); const provider = stopId.includes(':') ? stopId.split(':', 1)[0] : DEFAULT_PROVIDER; const namespaced = stopId.includes(':') ? stopId : `${provider}:${stopId}`; + const userStopCode = stopId.includes(':') ? stopId.split(':')[1] : stopId; const departures = await safeAwait( getDepartures({ stopId: namespaced, date: toYMD(effectiveDate) }), @@ -300,12 +355,13 @@ export class TrainAPIService { logger.debug(`[api] getTrainsForStation(${stopId}): ${departures.length} departures`); const trains = await Promise.all( - departures.map(async (d): Promise => { - const stops = await safeAwait( + departures.map(async (d): Promise => { + const apiStops = await safeAwait( getTripStops(d.tripId), [], ); - // ApiDepartureItem extends Trip (same shape), so we can pass it through. + const stops = apiStops.map(adaptStopTime); + const trip: ApiTrip = { providerId: d.providerId, tripId: d.tripId, @@ -316,10 +372,16 @@ export class TrainAPIService { shapeId: d.shapeId, directionId: d.directionId, }; - return buildTrainFromTrip(trip, stops.map(adaptStopTime), effectiveDate); + + if (stops.length > 0) { + const train = await buildTrainFromTrip(trip, stops, effectiveDate); + if (train) return train; + } + + return buildMinimalTrainFromDeparture(d, userStopCode, effectiveDate); }), ); - return trains.filter((t): t is Train => t !== null); + return trains; } catch (error) { logger.error('Error fetching trains for station:', error); return []; From 44b695dc118a890bd02f804c0eba426536512636 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 19:41:12 +0000 Subject: [PATCH 06/10] fix: apply CodeRabbit auto-fixes Fixed 9 file(s) based on 8 unresolved review comments. Co-authored-by: CodeRabbit --- apps/mobile/context/RealtimeContext.tsx | 21 ++++++++++---- apps/mobile/hooks/useLiveTrains.ts | 13 ++++++++- apps/mobile/hooks/useRealtime.ts | 29 ++++++++++++-------- apps/mobile/package.json | 1 + apps/mobile/screens/MapScreen.tsx | 2 +- apps/mobile/services/api.ts | 12 ++++---- apps/mobile/services/location-suggestions.ts | 4 +-- apps/mobile/services/ws-client.ts | 8 ++++++ pnpm-lock.yaml | 22 +++++++++++++-- 9 files changed, 83 insertions(+), 29 deletions(-) diff --git a/apps/mobile/context/RealtimeContext.tsx b/apps/mobile/context/RealtimeContext.tsx index 0fc639c..feeec18 100644 --- a/apps/mobile/context/RealtimeContext.tsx +++ b/apps/mobile/context/RealtimeContext.tsx @@ -50,18 +50,27 @@ export function RealtimeProvider({ providers, children }: RealtimeProviderProps) useEffect(() => { const ids = [...providerIds]; + // Build initial state with only current provider ids + const next: PositionsByProvider = {}; + for (const id of ids) { + next[id] = latestRef.current[id] || []; + } + latestRef.current = next; + setPositionsByProvider(next); + const onUpdate = (msg: RealtimeUpdate) => { // Warm the route cache so display sites can resolve routeId → name // synchronously without a flicker. for (const p of msg.positions) { if (p.routeId) prefetchRoute(p.routeId); } - const next: PositionsByProvider = { - ...latestRef.current, - [msg.provider]: msg.positions, - }; - latestRef.current = next; - setPositionsByProvider(next); + // Only keep current provider ids when merging updates + const updated: PositionsByProvider = {}; + for (const id of ids) { + updated[id] = id === msg.provider ? msg.positions : (latestRef.current[id] || []); + } + latestRef.current = updated; + setPositionsByProvider(updated); }; const unsubscribe = wsClient.subscribe(ids, onUpdate); return unsubscribe; diff --git a/apps/mobile/hooks/useLiveTrains.ts b/apps/mobile/hooks/useLiveTrains.ts index 19e93d4..57cbddf 100644 --- a/apps/mobile/hooks/useLiveTrains.ts +++ b/apps/mobile/hooks/useLiveTrains.ts @@ -51,11 +51,22 @@ export function useLiveTrains(_intervalMs: number = 15000, enabled: boolean = tr return out; }, [positions, enabled]); + const lastUpdated = useMemo(() => { + if (liveTrains.length === 0) return null; + let maxTimestamp: number | null = null; + for (const train of liveTrains) { + if (train.timestamp != null && (maxTimestamp === null || train.timestamp > maxTimestamp)) { + maxTimestamp = train.timestamp; + } + } + return maxTimestamp; + }, [liveTrains]); + return { liveTrains, loading: liveTrains.length === 0, error: null as Error | null, - lastUpdated: liveTrains.length > 0 ? Date.now() : null, + lastUpdated, refresh: () => Promise.resolve(), trainCount: liveTrains.length, }; diff --git a/apps/mobile/hooks/useRealtime.ts b/apps/mobile/hooks/useRealtime.ts index 58cac87..e2ba95c 100644 --- a/apps/mobile/hooks/useRealtime.ts +++ b/apps/mobile/hooks/useRealtime.ts @@ -46,21 +46,28 @@ export function useRealtime( const current = trainsRef.current; if (current.length === 0) return; let cancelled = false; + let timeoutId: ReturnType | null = null; - (async () => { - const updated = await Promise.all(current.map(t => TrainAPIService.refreshRealtimeData(t))); - if (cancelled) return; - const anyChanged = updated.some((t, i) => hasRealtimeChanged(t, current[i])); - if (anyChanged) { - setTrainsRef.current(updated); - } - TrainActivityManager.onRealtimeUpdate(current, updated).catch(e => - logger.warn('TrainActivityManager.onRealtimeUpdate failed', e), - ); - })(); + // Debounce rapid position updates + timeoutId = setTimeout(() => { + (async () => { + const updated = await Promise.all(current.map(t => TrainAPIService.refreshRealtimeData(t))); + if (cancelled) return; + // Stale-write guard: verify snapshot still matches + if (trainsRef.current !== current) return; + const anyChanged = updated.some((t, i) => hasRealtimeChanged(t, current[i])); + if (anyChanged) { + setTrainsRef.current(updated); + } + TrainActivityManager.onRealtimeUpdate(current, updated).catch(e => + logger.warn('TrainActivityManager.onRealtimeUpdate failed', e), + ); + })(); + }, 300); return () => { cancelled = true; + if (timeoutId) clearTimeout(timeoutId); }; }, [positions]); } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8351a3f..4bd33e9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -62,6 +62,7 @@ "@testing-library/react-native": "^13.3.3", "@types/jest": "29.5.14", "@types/react": "~19.2.10", + "@types/react-native-vector-icons": "^6.4.18", "dotenv": "^17.3.1", "eslint": "^9.25.0", "eslint-config-expo": "~55.0.0", diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index 4f25435..4192341 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -688,7 +688,7 @@ function MapScreenInner() { lat: payload.lat, lon: payload.lon, isCluster: false, - stations: [{ id: payload.stopCode, name: payload.name, lat: payload.lat, lon: payload.lon }], + stations: [{ id: payload.stopId, name: payload.name, lat: payload.lat, lon: payload.lon }], }); }, [handleStationPress], diff --git a/apps/mobile/services/api.ts b/apps/mobile/services/api.ts index 47d675b..a9acbeb 100644 --- a/apps/mobile/services/api.ts +++ b/apps/mobile/services/api.ts @@ -189,6 +189,7 @@ async function buildTrainFromTrip( const route = getCachedRoute(trip.routeId); const routeName = route?.longName || route?.shortName || ''; + const provider = trip.providerId || DEFAULT_PROVIDER; const departFormatted = convertGtfsTimeForStop(firstStop.departure_time, firstStop.stop_id); const arriveFormatted = convertGtfsTimeForStop(lastStop.arrival_time, lastStop.stop_id); @@ -198,8 +199,8 @@ async function buildTrainFromTrip( trainNumber: trip.shortName || extractTrainNumber(trip.tripId) || '', from: firstStop.stop_name, to: lastStop.stop_name, - fromCode: firstStop.stop_id, - toCode: lastStop.stop_id, + fromCode: `${provider}:${firstStop.stop_id}`, + toCode: `${provider}:${lastStop.stop_id}`, departTime: departFormatted.time, arriveTime: arriveFormatted.time, departDayOffset: departFormatted.dayOffset, @@ -214,7 +215,7 @@ async function buildTrainFromTrip( return { time: formatted.time, name: stop.stop_name, - code: stop.stop_id, + code: `${provider}:${stop.stop_id}`, }; }), }; @@ -243,6 +244,7 @@ function buildMinimalTrainFromDeparture( const route = getCachedRoute(d.routeId); const routeName = route?.longName || route?.shortName || ''; + const provider = d.providerId || DEFAULT_PROVIDER; const userTime = d.departureTime != null ? convertGtfsTimeForStop(d.departureTime, userStopCode) @@ -256,7 +258,7 @@ function buildMinimalTrainFromDeparture( trainNumber: d.shortName || extractTrainNumber(d.tripId) || '', from: '', to: d.headsign || '', - fromCode: userStopCode, + fromCode: `${provider}:${userStopCode}`, toCode: '', departTime: userTime.time, arriveTime: userTime.time, @@ -268,7 +270,7 @@ function buildMinimalTrainFromDeparture( routeName, tripId: d.tripId, intermediateStops: [ - { time: userTime.time, name: userStopCode, code: userStopCode }, + { time: userTime.time, name: userStopCode, code: `${provider}:${userStopCode}` }, ], }; diff --git a/apps/mobile/services/location-suggestions.ts b/apps/mobile/services/location-suggestions.ts index 9866ead..5274f9a 100644 --- a/apps/mobile/services/location-suggestions.ts +++ b/apps/mobile/services/location-suggestions.ts @@ -29,7 +29,7 @@ let initialized = false; function adaptStop(s: ApiStop): Stop { return { - stop_id: s.code, + stop_id: s.providerId ? `${s.providerId}:${s.code}` : s.code, stop_name: s.name, stop_lat: s.lat, stop_lon: s.lon, @@ -82,7 +82,7 @@ async function initialize(): Promise { accuracy: Location.Accuracy.Balanced, }); const { latitude, longitude } = location.coords; - logger.info(`[LocationSuggestions] Location: ${latitude.toFixed(3)}, ${longitude.toFixed(3)}`); + logger.info('[LocationSuggestions] Location received'); let nearby: ApiStop[] = []; try { diff --git a/apps/mobile/services/ws-client.ts b/apps/mobile/services/ws-client.ts index ddfc4e8..ab7b823 100644 --- a/apps/mobile/services/ws-client.ts +++ b/apps/mobile/services/ws-client.ts @@ -125,10 +125,18 @@ class WSClient { private ensureConnected(): void { if (this.state === 'connecting' || this.state === 'open') return; this.intentionallyClosed = false; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } this.connect(); } private connect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } this.state = 'connecting'; try { this.ws = new WebSocket(config.wsUrl); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d537ddc..ca89216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,9 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - expo-widgets@55.0.4: - hash: 919eee81369cbc6e5054e203ff6e8892a0c5e504fd1106481f9af102927d792f - path: patches/expo-widgets@55.0.4.patch + expo-widgets@55.0.4: 919eee81369cbc6e5054e203ff6e8892a0c5e504fd1106481f9af102927d792f importers: @@ -145,6 +143,9 @@ importers: '@types/react': specifier: ~19.2.10 version: 19.2.14 + '@types/react-native-vector-icons': + specifier: ^6.4.18 + version: 6.4.18 dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -2109,6 +2110,12 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-native-vector-icons@6.4.18': + resolution: {integrity: sha512-YGlNWb+k5laTBHd7+uZowB9DpIK3SXUneZqAiKQaj1jnJCZM0x71GDim5JCTMi4IFkhc9m8H/Gm28T5BjyivUw==} + + '@types/react-native@0.70.19': + resolution: {integrity: sha512-c6WbyCgWTBgKKMESj/8b4w+zWcZSsCforson7UdXtXMecG3MxCinYi6ihhrHVPyUrVzORsvEzK8zg32z4pK6Sg==} + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -8977,6 +8984,15 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-native-vector-icons@6.4.18': + dependencies: + '@types/react': 19.2.14 + '@types/react-native': 0.70.19 + + '@types/react-native@0.70.19': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 From 92aa5b0a4ee714e0361916c1a7d504c2ab5e359a Mon Sep 17 00:00:00 2001 From: Mootbing <50122069+Mootbing@users.noreply.github.com> Date: Sun, 10 May 2026 10:49:37 -0700 Subject: [PATCH 07/10] Update map styles to Apple Maps-inspired light and dark themes (#40) Replace OpenFreeMap liberty styles with custom Apple Maps-inspired color palettes for both light and dark modes. Co-authored-by: Jason Xu Co-authored-by: Claude Opus 4.6 (1M context) --- apps/mobile/assets/apple-dark-style.json | 1 + apps/mobile/assets/apple-light-style.json | 1 + apps/mobile/constants/map-styles.ts | 13 ++++++++----- 3 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/assets/apple-dark-style.json create mode 100644 apps/mobile/assets/apple-light-style.json diff --git a/apps/mobile/assets/apple-dark-style.json b/apps/mobile/assets/apple-dark-style.json new file mode 100644 index 0000000..329eb57 --- /dev/null +++ b/apps/mobile/assets/apple-dark-style.json @@ -0,0 +1 @@ +{"version":8,"name":"Apple Dark","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#1c1c1e"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#1a2e1a","fill-opacity":0.7}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#222224"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#1a2e1a","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#1e301c","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#2a3438","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#2e2a20"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#1e301c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#1e2a1c"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#2a2024"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#28261e"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#152838","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#152838"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#28282a","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2a2a2c","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#28262a","fill-outline-color":"#38363a"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#28262a","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#4a3860","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4870","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#5a4870","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#98989d","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#78787c","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#98989d","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#b0b0b4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#c0c0c4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/assets/apple-light-style.json b/apps/mobile/assets/apple-light-style.json new file mode 100644 index 0000000..04ac8c1 --- /dev/null +++ b/apps/mobile/assets/apple-light-style.json @@ -0,0 +1 @@ +{"version":8,"name":"Apple Light","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f5f0"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#c8e6a0","fill-opacity":0.6}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#f2efe9"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#c8dfab","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#d4e8b8","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#e8f0f4","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#f5ebd6"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#b8d88c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#d4e2c8"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#f8e8e8"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#f2ecd8"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a8d4e6","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#a8d4e6"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#e8e4e0","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#e8e4e0","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0dcd8","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#d8d4d0","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#f8f5f0","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#e8e4e0","fill-outline-color":"#d8d4d0"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#e8e4e0","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#c0b0d0","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#a090c0","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#a090c0","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#6e6e73","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/constants/map-styles.ts b/apps/mobile/constants/map-styles.ts index 2b26caa..f11fdcf 100644 --- a/apps/mobile/constants/map-styles.ts +++ b/apps/mobile/constants/map-styles.ts @@ -1,6 +1,9 @@ -/** MapLibre vector tile style URLs (CARTO - free, no API key required) */ -export const MAP_STYLE = { - standard: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', - dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', - satellite: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', +import type { StyleSpecification } from '@maplibre/maplibre-react-native'; +import appleLight from '../assets/apple-light-style.json'; +import appleDark from '../assets/apple-dark-style.json'; + +/** MapLibre vector tile styles (OpenFreeMap - free, no API key required) */ +export const MAP_STYLE: Record = { + standard: appleLight as StyleSpecification, + dark: appleDark as StyleSpecification, }; From c095c6694da8818853317e2eb4dcd762fdb67306 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Sun, 10 May 2026 16:55:16 -0500 Subject: [PATCH 08/10] refactor: Replace GTFSParser with API-backed stop cache for station loading - Removed the GTFSParser class and its associated methods, transitioning to a synchronous API-backed cache for stop lookups. - Introduced `lookupStop` and `lookupAgencyTimezone` functions to fetch stop data and agency timezone directly from the API client cache. - Updated `StationLoader` to use the new cache for retrieving station information by code. - Modified utility functions to leverage the new API cache, ensuring consistent behavior across the application. - Cleaned up related imports and removed unused GTFS parsing logic. --- README.md | 100 +-- .../__tests__/utils/train-helpers.test.ts | 19 +- apps/mobile/app.config.ts | 2 +- apps/mobile/assets/apple-dark-style.json | 2 +- apps/mobile/assets/apple-light-style.json | 2 +- apps/mobile/components/TrainCardContent.tsx | 10 +- apps/mobile/components/TwoStationSearch.tsx | 476 +++++++--- .../components/ui/DepartureBoardModal.tsx | 12 +- .../mobile/components/ui/TrainDetailModal.tsx | 112 ++- .../ui/train-detail/DepartureArrivalBoard.tsx | 11 +- apps/mobile/constants/config.ts | 2 +- apps/mobile/context/GTFSRefreshContext.tsx | 181 +--- apps/mobile/hooks/useApiCache.ts | 64 ++ apps/mobile/hooks/useShapes.ts | 36 - apps/mobile/hooks/useStations.ts | 48 - apps/mobile/hooks/useTravelOverlay.ts | 63 +- apps/mobile/hooks/useTripDetail.ts | 183 ++++ apps/mobile/package.json | 2 - apps/mobile/screens/MapScreen.tsx | 1 - apps/mobile/services/api-client.ts | 136 ++- apps/mobile/services/calendar-sync.ts | 582 +----------- apps/mobile/services/gtfs-sync.ts | 465 ---------- apps/mobile/services/realtime.ts | 432 --------- apps/mobile/services/shape-loader.ts | 163 ---- apps/mobile/services/station-loader.ts | 102 +-- apps/mobile/types/api.ts | 8 + apps/mobile/utils/api-stop-cache.ts | 52 ++ apps/mobile/utils/gtfs-parser.ts | 841 ------------------ apps/mobile/utils/timezone.ts | 11 +- apps/mobile/utils/train-display.ts | 6 +- apps/mobile/utils/train-helpers.ts | 12 +- 31 files changed, 1030 insertions(+), 3106 deletions(-) create mode 100644 apps/mobile/hooks/useApiCache.ts delete mode 100644 apps/mobile/hooks/useShapes.ts delete mode 100644 apps/mobile/hooks/useStations.ts create mode 100644 apps/mobile/hooks/useTripDetail.ts delete mode 100644 apps/mobile/services/gtfs-sync.ts delete mode 100644 apps/mobile/services/realtime.ts delete mode 100644 apps/mobile/services/shape-loader.ts create mode 100644 apps/mobile/utils/api-stop-cache.ts delete mode 100644 apps/mobile/utils/gtfs-parser.ts diff --git a/README.md b/README.md index e16beca..ab6a9f3 100644 --- a/README.md +++ b/README.md @@ -306,61 +306,51 @@ On startup, the app checks for locally cached GTFS data. If the cache is missing - **Real-time cache** — 15s TTL prevents redundant API calls - **Reanimated animations** — all transitions run at 60 fps on the native thread -## API Usage - -### Get All Active Trains - -```typescript -import { RealtimeService } from './services/realtime'; - -const trains = await RealtimeService.getAllActiveTrains(); -// Returns ~150-160 active trains with position, speed, bearing -``` - -### Get a Specific Train's Position - -```typescript -const position = await RealtimeService.getPositionForTrip('543'); -// Accepts train number ("543") or full trip ID ("2026-01-16_AMTK_543") -``` - -### Check Delay at a Stop - -```typescript -const delay = await RealtimeService.getDelayForStop('543', 'NYP'); -console.log(RealtimeService.formatDelay(delay)); -// "On Time", "Delayed 5m", or "Early 2m" -``` - -### Get Full Train Details (Schedule + Real-Time) - -```typescript -import { TrainAPIService } from './services/api'; - -const train = await TrainAPIService.getTrainDetails('543'); -// Includes full itinerary, real-time position, and delay status -``` - -### Search Stations - -```typescript -import { gtfsParser } from './utils/gtfs-parser'; - -const stations = await gtfsParser.searchStations('Boston'); -``` - -### Find Trips Between Two Stations - -```typescript -const trips = await gtfsParser.findTripsWithStops('BOS', 'NYP'); -``` - -### Refresh Real-Time Data for a Saved Train - -```typescript -const updated = await TrainAPIService.refreshRealtimeData(existingSavedTrain); -// updated.realtime now has the latest position and delay -``` +## API Surface (High Level) + +Tracky mobile is wired to the backend via: + +- REST base URL: `https://api.trackyapp.net` (default from `apps/mobile/constants/config.ts`) +- WebSocket URL: `wss://api.trackyapp.net/ws/realtime` (default from `apps/mobile/constants/config.ts`) + +The endpoint contracts are wrapped in `apps/mobile/services/api-client.ts`, with higher-level train/domain adapters in `apps/mobile/services/api.ts`. + +> Note: `apps/api/cmd/api/main.go` in this workspace currently exposes a minimal local server (`/health`). The `/v1/*` surface below reflects the API expected by the mobile app client. + +### Endpoint Map (REST) + +| Endpoint | Purpose | Maps to app behavior | +| --- | --- | --- | +| `GET /health` | Liveness check for local API process | Local backend smoke check | +| `GET /v1/search?q=&provider=&types=` | Unified search across stations, trains, routes | Search modal suggestions and station-only search flow | +| `GET /v1/providers/{provider}` | Provider metadata (timezone, name, etc.) | Agency/timezone lookups used by cached stop/agency helpers | +| `GET /v1/stops/{provider}/{stopCode}` | Stop metadata by code | Trip detail stop enrichment and station metadata hydration | +| `GET /v1/stops/nearby?lat=&lon=&radius_m=&provider=` | Nearby stations around location | Search screen nearby suggestions (best-effort) | +| `GET /v1/routes?provider=` | List routes for provider | Popular route suggestions in search | +| `GET /v1/routes/{provider}/{routeCode}` | Single route metadata | Route selection hydration and route-name cache prefetch | +| `GET /v1/routes/{provider}/{routeCode}/trains` | Trains that run on a route | Route expansion into train list in search | +| `GET /v1/trains/{trainNumber}/service?provider=&from=&to=` | Service date range for a train number | Bounds the train-number date picker | +| `GET /v1/trips/lookup?provider=&train_number=&date=` | Resolve train number + date into trip IDs | Train-number search flow and train detail resolution | +| `GET /v1/trips/{tripId}` | Trip metadata (route/headsign/service) | `TrainAPIService.getTrainDetails` trip resolution | +| `GET /v1/trips/{tripId}/stops` | Scheduled stop timeline for trip | Train detail timeline, trip selection, saved-trip reconstruction | +| `GET /v1/departures?stop_id=&date=` | Departures/arrivals for a station on a date | Station departure board and nearby suggestion train rows | +| `GET /v1/connections?from_stop=&to_stop=&date=` | Station-to-station trip options | Two-station search results | +| `GET /v1/runs/{provider}/{tripId}/{runDate}/stops` | Per-stop scheduled/estimated/actual realtime rows | Live delay overlays in trip search and trip detail | +| `GET /v1/active?provider=` | Currently active runs snapshot | "Live only" filtering for route train lists | + +### Realtime Stream (WebSocket) + +| Endpoint | Purpose | Maps to app behavior | +| --- | --- | --- | +| `WS /ws/realtime` | Pushes `realtime_update` snapshots by provider after subscribe/unsubscribe messages | Live train markers on map, saved-train realtime refresh, and route-name prefetch warming | + +### Where This Is Consumed in the App + +- Search and trip planning: `apps/mobile/components/TwoStationSearch.tsx` +- Departure boards: `apps/mobile/components/ui/DepartureBoardModal.tsx` +- Train detail per-stop enrichment: `apps/mobile/hooks/useTripDetail.ts` +- Saved train reconstruction and refresh: `apps/mobile/services/storage.ts`, `apps/mobile/services/api.ts` +- Live map and realtime fanout: `apps/mobile/context/RealtimeContext.tsx`, `apps/mobile/services/ws-client.ts`, `apps/mobile/hooks/useLiveTrains.ts` ## Tech Stack diff --git a/apps/mobile/__tests__/utils/train-helpers.test.ts b/apps/mobile/__tests__/utils/train-helpers.test.ts index c04a451..cb7cff0 100644 --- a/apps/mobile/__tests__/utils/train-helpers.test.ts +++ b/apps/mobile/__tests__/utils/train-helpers.test.ts @@ -1,16 +1,13 @@ import { extractTrainNumber, isLikelyTrainNumber } from '../../utils/train-helpers'; -// Mock the gtfsParser -jest.mock('../../utils/gtfs-parser', () => ({ - gtfsParser: { - getTrainNumber: jest.fn((tripId: string) => { - // Simulate GTFS parser behavior — returns trip_short_name or null - if (tripId === 'Amtrak-43-20240104') return '43'; - if (tripId === '2151') return '2151'; - // Simulate GTFS lookup miss — returns null - return null; - }), - }, +// Mock the API-client trip cache used by train-helpers. +jest.mock('../../services/api-client', () => ({ + getCachedTrip: jest.fn((tripId: string) => { + if (tripId === 'Amtrak-43-20240104') return { shortName: '43' }; + if (tripId === '2151') return { shortName: '2151' }; + return undefined; + }), + prefetchTrip: jest.fn(), })); describe('train-helpers utilities', () => { diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index ed745bc..db7f2d4 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -128,7 +128,7 @@ const config: ExpoConfig = { }, apiUrl: process.env.EXPO_PUBLIC_API_URL ?? "https://api.trackyapp.net", wsUrl: process.env.EXPO_PUBLIC_WS_URL ?? "wss://api.trackyapp.net/ws/realtime", - tilesUrl: process.env.EXPO_PUBLIC_TILES_URL ?? "https://tiles.trytracky.com", + tilesUrl: process.env.EXPO_PUBLIC_TILES_URL ?? "https://tiles.trackyapp.net", }, owner: "railforless", }; diff --git a/apps/mobile/assets/apple-dark-style.json b/apps/mobile/assets/apple-dark-style.json index 329eb57..a7248e5 100644 --- a/apps/mobile/assets/apple-dark-style.json +++ b/apps/mobile/assets/apple-dark-style.json @@ -1 +1 @@ -{"version":8,"name":"Apple Dark","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#1c1c1e"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#1a2e1a","fill-opacity":0.7}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#222224"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#1a2e1a","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#1e301c","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#2a3438","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#2e2a20"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#1e301c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#1e2a1c"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#2a2024"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#28261e"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#152838","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#152838"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#28282a","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2a2a2c","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#28262a","fill-outline-color":"#38363a"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#28262a","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#4a3860","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4870","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#5a4870","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#98989d","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#78787c","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#98989d","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#b0b0b4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#c0c0c4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}}]} \ No newline at end of file +{"version":8,"name":"Apple Dark","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#1c1c1e"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#1a2e1a","fill-opacity":0.7}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#222224"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#1a2e1a","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#1e301c","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#2a3438","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#2e2a20"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#1e301c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#1e2a1c"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#2a2024"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#28261e"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#152838","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#152838"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#28282a","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2a2a2c","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#28262a","fill-outline-color":"#38363a"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#28262a","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#4a3860","line-dasharray":[1,1],"line-width":["interpolate",["linear"],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4870","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#5a4870","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#98989d","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#78787c","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#98989d","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#b0b0b4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#c0c0c4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/assets/apple-light-style.json b/apps/mobile/assets/apple-light-style.json index 04ac8c1..81b85ce 100644 --- a/apps/mobile/assets/apple-light-style.json +++ b/apps/mobile/assets/apple-light-style.json @@ -1 +1 @@ -{"version":8,"name":"Apple Light","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f5f0"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#c8e6a0","fill-opacity":0.6}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#f2efe9"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#c8dfab","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#d4e8b8","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#e8f0f4","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#f5ebd6"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#b8d88c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#d4e2c8"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#f8e8e8"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#f2ecd8"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a8d4e6","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#a8d4e6"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#e8e4e0","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#e8e4e0","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0dcd8","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#d8d4d0","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#f8f5f0","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#e8e4e0","fill-outline-color":"#d8d4d0"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#e8e4e0","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#c0b0d0","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#a090c0","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#a090c0","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#6e6e73","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}}]} \ No newline at end of file +{"version":8,"name":"Apple Light","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f5f0"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#c8e6a0","fill-opacity":0.6}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#f2efe9"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#c8dfab","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#d4e8b8","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#e8f0f4","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#f5ebd6"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#b8d88c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#d4e2c8"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#f8e8e8"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#f2ecd8"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a8d4e6","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#a8d4e6"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#e8e4e0","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#e8e4e0","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0dcd8","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#d8d4d0","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#f8f5f0","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#e8e4e0","fill-outline-color":"#d8d4d0"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#e8e4e0","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#c0b0d0","line-dasharray":[1,1],"line-width":["interpolate",["linear"],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#a090c0","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#a090c0","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#6e6e73","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/components/TrainCardContent.tsx b/apps/mobile/components/TrainCardContent.tsx index de2d83c..64a5829 100644 --- a/apps/mobile/components/TrainCardContent.tsx +++ b/apps/mobile/components/TrainCardContent.tsx @@ -3,10 +3,11 @@ import { Image, StyleSheet, Text, View } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import { type ColorPalette, FontSizes, Spacing, withTextShadow } from '../constants/theme'; import { useColors } from '../context/ThemeContext'; +import { useApiCacheVersion } from '../hooks/useApiCache'; import { createStyles } from '../screens/styles'; import { getDelayColorKey, parseTimeToMinutes } from '../utils/time-formatting'; import { pluralCount } from '../utils/train-display'; -import { gtfsParser } from '../utils/gtfs-parser'; +import { lookupAgencyTimezone, lookupStop } from '../utils/api-stop-cache'; import { getCurrentSecondsInTimezone, getTimezoneForStop } from '../utils/timezone'; import AnimatedRollingText from './ui/AnimatedRollingText'; import MarqueeText from './ui/MarqueeText'; @@ -66,6 +67,7 @@ export default function TrainCardContent({ const colors = useColors(); const styles = useMemo(() => createStyles(colors), [colors]); const localStyles = useMemo(() => createLocalStyles(colors), [colors]); + const cacheVersion = useApiCacheVersion(); const DELAY_COLORS = { delayed: colors.delayed, @@ -76,14 +78,14 @@ export default function TrainCardContent({ const isArrived = useMemo(() => { if (!isPast || !arriveTime) return false; - const toStop = gtfsParser.getStop(toCode); - const arriveTz = toStop ? getTimezoneForStop(toStop) : gtfsParser.agencyTimezone; + const toStop = lookupStop(toCode); + const arriveTz = toStop ? getTimezoneForStop(toStop) : lookupAgencyTimezone(); const nowSec = getCurrentSecondsInTimezone(arriveTz); const arriveSec = parseTimeToMinutes(arriveTime) * 60 + (arriveDayOffset ?? 0) * 24 * 3600; const delaySec = (arriveDelayMinutes ?? 0) * 60; return nowSec >= arriveSec + delaySec + (daysAway ?? 0) * 86400; - }, [isPast, arriveTime, arriveDayOffset, arriveDelayMinutes, toCode, daysAway]); + }, [isPast, arriveTime, arriveDayOffset, arriveDelayMinutes, toCode, daysAway, cacheVersion]); const shouldFadeTitle = fadeOnlyOnArrival ? isArrived : isPast; const pastColor = isPast ? { color: colors.secondary } : undefined; diff --git a/apps/mobile/components/TwoStationSearch.tsx b/apps/mobile/components/TwoStationSearch.tsx index 9b32222..94f95e2 100644 --- a/apps/mobile/components/TwoStationSearch.tsx +++ b/apps/mobile/components/TwoStationSearch.tsx @@ -3,18 +3,155 @@ import { Dimensions, Keyboard, Platform, StyleSheet, TextInput } from 'react-nat import { type ColorPalette, BorderRadius, FontSizes, Spacing, withTextShadow } from '../constants/theme'; import { useTheme } from '../context/ThemeContext'; import { light as hapticLight, selection as hapticSelection, success as hapticSuccess } from '../utils/haptics'; -import { RealtimeService } from '../services/realtime'; +import { + getActiveTrains, + getConnections, + getRoute, + getRoutes, + getRunStops, + getTrainService, + getTrainsForRoute, + getTripStops, + lookupTrips, + search as apiSearch, +} from '../services/api-client'; import { TrainStorageService } from '../services/storage'; -import type { EnrichedStopTime, Route, SearchResult, Stop } from '../types/train'; +import type { + ApiConnectionItem, + ApiEnrichedStopTime, + ApiRoute, + ApiSearchHit, + ApiTrainItem, +} from '../types/api'; +import type { EnrichedStopTime, Route, SearchResult, Stop, Trip } from '../types/train'; import { useTrainContext } from '../context/TrainContext'; import { SlideUpModalContext } from './ui/SlideUpModal'; -import { gtfsParser } from '../utils/gtfs-parser'; +import { useApiCacheVersion } from '../hooks/useApiCache'; +import { lookupAgencyTimezone, lookupStop } from '../utils/api-stop-cache'; import { logger } from '../utils/logger'; import { LocationSuggestionsService } from '../services/location-suggestions'; import { pluralCount } from '../utils/train-display'; import { formatDateForDisplay } from '../utils/date-helpers'; import type { DateData } from 'react-native-calendars'; +const PROVIDER_ID = 'amtrak'; + +function bareCode(namespacedId: string): string { + const i = namespacedId.indexOf(':'); + return i > 0 ? namespacedId.slice(i + 1) : namespacedId; +} + +function ymd(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function computeDelayMinutes( + scheduledIso: string | null | undefined, + liveIso: string | null | undefined, +): number | null { + if (!scheduledIso || !liveIso) return null; + const a = Date.parse(scheduledIso); + const b = Date.parse(liveIso); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + return Math.round((b - a) / 60000); +} + +function apiRouteToLegacy(r: ApiRoute): Route { + return { + route_id: r.routeId, + route_long_name: r.longName, + route_short_name: r.shortName, + route_color: r.color, + route_text_color: r.textColor, + }; +} + +function apiEnrichedStopTimeToLegacy(s: ApiEnrichedStopTime): EnrichedStopTime { + return { + trip_id: s.tripId, + arrival_time: s.arrivalTime ?? '', + departure_time: s.departureTime ?? '', + stop_id: s.stopId, + stop_sequence: s.stopSequence, + stop_name: s.stopName, + stop_code: s.stopCode, + pickup_type: s.pickupType ?? undefined, + drop_off_type: s.dropOffType ?? undefined, + timepoint: s.timepoint == null ? undefined : (s.timepoint ? 1 : 0), + }; +} + +function apiConnectionToTripResult(c: ApiConnectionItem): TripResult { + return { + tripId: c.tripId, + fromStop: apiEnrichedStopTimeToLegacy(c.from), + toStop: apiEnrichedStopTimeToLegacy(c.to), + intermediateStops: c.intermediate.map(apiEnrichedStopTimeToLegacy), + }; +} + +function apiTrainItemToRouteTrainItem(t: ApiTrainItem): RouteTrainItem { + return { + trainNumber: t.trainNumber, + displayName: t.trainNumber, + headsign: t.sampleHeadsign, + endpointLabel: '', + }; +} + +function stationHitToStop(hit: ApiSearchHit): Stop { + const code = bareCode(hit.id); + const cached = lookupStop(code); + if (cached) return cached; + // Synthetic placeholder; downstream code only reads stop_id/stop_name, and + // lookupStop will surface the real coords once the cache fills. + return { + stop_id: code, + stop_name: hit.name, + stop_lat: 0, + stop_lon: 0, + }; +} + +function searchHitToResult(hit: ApiSearchHit): SearchResult { + if (hit.type === 'station') { + return { + id: hit.id, + name: hit.name, + subtitle: hit.subtitle, + type: 'station', + data: stationHitToStop(hit), + }; + } + if (hit.type === 'route') { + const code = bareCode(hit.id); + return { + id: hit.id, + name: hit.name, + subtitle: hit.subtitle, + type: 'route', + data: { + route_id: hit.id, + route_long_name: hit.name, + route_short_name: code, + } as Route, + }; + } + // train + return { + id: hit.id, + name: hit.name, + subtitle: hit.subtitle, + type: 'train', + data: { + trip_id: hit.id, + trip_short_name: bareCode(hit.id), + route_id: '', + service_id: '', + } as Trip, + }; +} + import { SearchResultsList } from './search/SearchResultsList'; import { TrainFlowView } from './search/TrainFlowView'; import { StationFlowView } from './search/StationFlowView'; @@ -70,7 +207,7 @@ export function getCountdownFromDeparture(departureTime: string, travelDate: Dat const departSecOfDay = h * 3600 + m * 60 // handles GTFS h>=24 naturally + (delayMinutes && delayMinutes > 0 ? delayMinutes * 60 : 0); - const tz = gtfsParser.agencyTimezone; + const tz = lookupAgencyTimezone(); const now = new Date(); const nowSec = getCurrentSecondsInTimezone(tz); @@ -145,8 +282,10 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp const [searchQuery, setSearchQuery] = useState(''); const [selectedDate, setSelectedDate] = useState(null); const [showDatePicker, setShowDatePicker] = useState(false); - const [isDataLoaded, setIsDataLoaded] = useState(gtfsParser.isLoaded); const searchInputRef = useRef(null); + // Subscribe to api-client cache so synthetic Stop placeholders re-resolve to + // real coords once a fetch lands. + useApiCacheVersion(); // --- Station flow state (Path 2a) --- const [fromStation, setFromStation] = useState(null); @@ -188,24 +327,25 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp }, [savedTrains]); useEffect(() => { - if (!isDataLoaded) return; + let cancelled = false; // Popular (always shown) - const allRoutes = gtfsParser.getAllRoutes(); - const popular: SuggestionItem[] = []; - const nerRoute = allRoutes.find(r => r.route_long_name.toLowerCase().includes('northeast regional')); - if (nerRoute) { - popular.push({ type: 'route', label: 'Northeast Regional', subtitle: 'Route', routeId: nerRoute.route_id }); - } - const acelaRoute = allRoutes.find(r => r.route_long_name.toLowerCase().includes('acela')); - if (acelaRoute) { - popular.push({ type: 'route', label: 'Acela', subtitle: 'Route', routeId: acelaRoute.route_id }); - } - const nyp = gtfsParser.getStop('NYP'); - if (nyp) { - popular.push({ type: 'station', label: nyp.stop_name, subtitle: 'NYP \u00B7 Station', stop: nyp }); - } - setPopularSuggestions(popular); + (async () => { + try { + const allRoutes = await getRoutes(PROVIDER_ID); + if (cancelled) return; + const popular: SuggestionItem[] = []; + const ner = allRoutes.find(r => r.longName.toLowerCase().includes('northeast regional')); + if (ner) popular.push({ type: 'route', label: 'Northeast Regional', subtitle: 'Route', routeId: ner.routeId }); + const acela = allRoutes.find(r => r.longName.toLowerCase().includes('acela')); + if (acela) popular.push({ type: 'route', label: 'Acela', subtitle: 'Route', routeId: acela.routeId }); + const nyp = lookupStop('NYP'); + if (nyp) popular.push({ type: 'station', label: nyp.stop_name, subtitle: 'NYP \u00B7 Station', stop: nyp }); + setPopularSuggestions(popular); + } catch (e) { + logger.warn('[Search] failed to load popular routes', e); + } + })(); // Nearby (from location service) const locationSuggestions = LocationSuggestionsService.getCachedSuggestions(); @@ -215,95 +355,129 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp // History TrainStorageService.getTripHistory().then(history => { - if (history.length > 0) { - const routeCounts = new Map(); - for (const trip of history) { - const key = `${trip.fromCode}-${trip.toCode}`; - const existing = routeCounts.get(key); - if (existing) { - existing.count++; - } else { - routeCounts.set(key, { count: 1, routeName: trip.routeName, fromCode: trip.fromCode, toCode: trip.toCode }); - } + if (cancelled || history.length === 0) return; + const routeCounts = new Map(); + for (const trip of history) { + const key = `${trip.fromCode}-${trip.toCode}`; + const existing = routeCounts.get(key); + if (existing) { + existing.count++; + } else { + routeCounts.set(key, { count: 1, routeName: trip.routeName, fromCode: trip.fromCode, toCode: trip.toCode }); } - const sorted = [...routeCounts.values()].sort((a, b) => b.count - a.count).slice(0, 1); - setHistorySuggestions(sorted.map(r => ({ - type: 'station' as const, - label: `${r.fromCode} \u2192 ${r.toCode}`, - subtitle: `${r.routeName} \u00B7 ${pluralCount(r.count, 'trip')}`, - stop: gtfsParser.getStop(r.fromCode) || undefined, - toStop: gtfsParser.getStop(r.toCode) || undefined, - }))); } + const sorted = [...routeCounts.values()].sort((a, b) => b.count - a.count).slice(0, 1); + setHistorySuggestions(sorted.map(r => ({ + type: 'station' as const, + label: `${r.fromCode} \u2192 ${r.toCode}`, + subtitle: `${r.routeName} \u00B7 ${pluralCount(r.count, 'trip')}`, + stop: lookupStop(r.fromCode) || undefined, + toStop: lookupStop(r.toCode) || undefined, + }))); }); - }, [isDataLoaded]); - // --- Train service date range (for constraining date picker in train flow) --- - const trainServiceInfo = useMemo(() => { - if (!selectedTrainNumber || !isDataLoaded) return null; - return gtfsParser.getServiceInfoForTrain(selectedTrainNumber); - }, [selectedTrainNumber, isDataLoaded]); + return () => { cancelled = true; }; + }, []); - // Check if GTFS data is loaded + // --- Train service date range (for constraining date picker in train flow) --- + const [trainServiceInfo, setTrainServiceInfo] = useState<{ minDate: Date; maxDate: Date } | null>(null); useEffect(() => { - const checkLoaded = () => { - if (gtfsParser.isLoaded && !isDataLoaded) { - setIsDataLoaded(true); - } - }; - checkLoaded(); - const interval = setInterval(checkLoaded, 500); - return () => clearInterval(interval); - }, [isDataLoaded]); + if (!selectedTrainNumber) { + setTrainServiceInfo(null); + return; + } + let cancelled = false; + getTrainService(selectedTrainNumber, { provider: PROVIDER_ID }) + .then(info => { + if (cancelled) return; + // ApiServiceInfo has YYYY-MM-DD strings; convert to Date. + const [yMin, mMin, dMin] = info.minDate.split('-').map(Number); + const [yMax, mMax, dMax] = info.maxDate.split('-').map(Number); + setTrainServiceInfo({ + minDate: new Date(yMin, mMin - 1, dMin), + maxDate: new Date(yMax, mMax - 1, dMax), + }); + }) + .catch(e => { + logger.warn('[Search] failed to load train service', e); + if (!cancelled) setTrainServiceInfo(null); + }); + return () => { cancelled = true; }; + }, [selectedTrainNumber]); // Search logic -- branches based on current state useEffect(() => { - if (!isDataLoaded || searchQuery.length === 0) { + if (searchQuery.length === 0) { setUnifiedResults({ trains: [], routes: [], stations: [] }); setStationResults([]); return; } + let cancelled = false; if (fromStation && !toStation) { // Station flow: picking arrival station - const results = gtfsParser.searchStations(searchQuery); - setStationResults(results); + apiSearch({ q: searchQuery, provider: PROVIDER_ID, types: ['station'] }) + .then(res => { + if (cancelled) return; + setStationResults(res.stations.map(stationHitToStop)); + }) + .catch(e => logger.warn('[Search] station search failed', e)); } else if (!fromStation && !selectedTrainNumber && !expandedRouteTrains) { // Initial view: unified search (skip when filtering within a route) - const results = gtfsParser.searchUnified(searchQuery); - setUnifiedResults(results); + apiSearch({ q: searchQuery, provider: PROVIDER_ID }) + .then(res => { + if (cancelled) return; + setUnifiedResults({ + trains: res.trains.map(searchHitToResult), + routes: res.routes.map(searchHitToResult), + stations: res.stations.map(searchHitToResult), + }); + }) + .catch(e => logger.warn('[Search] unified search failed', e)); } - }, [searchQuery, isDataLoaded, fromStation, toStation, selectedTrainNumber, expandedRouteTrains]); + return () => { cancelled = true; }; + }, [searchQuery, fromStation, toStation, selectedTrainNumber, expandedRouteTrains]); // Find trips when both stations AND date are selected (station flow) useEffect(() => { - if (fromStation && toStation && selectedDate) { - setLoadingTrips(true); - setTripResults([]); - // Defer heavy work so skeleton paints first - const timeout = setTimeout(() => { - logger.info( - `[Search] Finding trips: ${fromStation.stop_name} \u2192 ${toStation.stop_name} on ${selectedDate.toLocaleDateString()}` - ); - const trips = gtfsParser.findTripsWithStops(fromStation.stop_id, toStation.stop_id, selectedDate); - logger.info(`[Search] Found ${trips.length} trips`); - setTripResults(trips); - setLoadingTrips(false); - }, 50); - return () => clearTimeout(timeout); - } else { + if (!fromStation || !toStation || !selectedDate) { setTripResults([]); setLoadingTrips(false); + return; } + setLoadingTrips(true); + setTripResults([]); + let cancelled = false; + logger.info( + `[Search] Finding trips: ${fromStation.stop_name} \u2192 ${toStation.stop_name} on ${selectedDate.toLocaleDateString()}` + ); + getConnections({ + fromStop: fromStation.stop_id, + toStop: toStation.stop_id, + date: ymd(selectedDate), + }) + .then(conns => { + if (cancelled) return; + logger.info(`[Search] Found ${conns.length} trips`); + setTripResults(conns.map(apiConnectionToTripResult)); + setLoadingTrips(false); + }) + .catch(e => { + if (!cancelled) { + logger.warn('[Search] connection lookup failed', e); + setTripResults([]); + setLoadingTrips(false); + } + }); + return () => { cancelled = true; }; }, [fromStation, toStation, selectedDate]); - // Fetch delays for today's search results + // Fetch delays for today's search results \u2014 derived from /v1/runs/.../stops useEffect(() => { if (tripResults.length === 0 || !selectedDate) { setTripDelays(new Map()); return; } - // Only fetch delays if the selected date is today const now = new Date(); const isToday = selectedDate.getFullYear() === now.getFullYear() && @@ -315,19 +489,32 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp } let cancelled = false; + const runDate = ymd(selectedDate); + const fetchDelays = async () => { const delays = new Map(); await Promise.all( tripResults.map(async (trip) => { - const [depDelay, arrDelay] = await Promise.all([ - RealtimeService.getDelayForStop(trip.tripId, trip.fromStop.stop_id), - RealtimeService.getArrivalDelayForStop(trip.tripId, trip.toStop.stop_id), - ]); - if (depDelay != null || arrDelay != null) { - delays.set(trip.tripId, { - departDelay: depDelay ?? undefined, - arriveDelay: arrDelay ?? undefined, + try { + const stops = await getRunStops({ + provider: PROVIDER_ID, + tripId: trip.tripId, + runDate, }); + const fromCode = trip.fromStop.stop_code || trip.fromStop.stop_id; + const toCode = trip.toStop.stop_code || trip.toStop.stop_id; + const fromRow = stops.find(s => s.stopCode === fromCode); + const toRow = stops.find(s => s.stopCode === toCode); + const departDelay = computeDelayMinutes(fromRow?.scheduledDep, fromRow?.estimatedDep ?? fromRow?.actualDep); + const arriveDelay = computeDelayMinutes(toRow?.scheduledArr, toRow?.estimatedArr ?? toRow?.actualArr); + if (departDelay != null || arriveDelay != null) { + delays.set(trip.tripId, { + departDelay: departDelay ?? undefined, + arriveDelay: arriveDelay ?? undefined, + }); + } + } catch { + // No live data for this run yet \u2014 leave delays unset. } }) ); @@ -354,25 +541,36 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp setTrainNotRunning(false); return; } - const trip = gtfsParser.getTripForTrainOnDate(selectedTrainNumber, selectedDate); - if (!trip) { - setTrainNotRunning(true); - setResolvedTripId(null); - setTrainStops([]); - return; - } - // Check if this trip's service is actually active on the date - const isActive = gtfsParser.isServiceActiveOnDate(trip.service_id, selectedDate); - if (!isActive) { - setTrainNotRunning(true); - setResolvedTripId(null); - setTrainStops([]); - return; - } - setTrainNotRunning(false); - setResolvedTripId(trip.trip_id); - const stops = gtfsParser.getStopTimesForTrip(trip.trip_id); - setTrainStops(stops); + let cancelled = false; + (async () => { + try { + const trips = await lookupTrips({ + provider: PROVIDER_ID, + trainNumber: selectedTrainNumber, + date: ymd(selectedDate), + }); + if (cancelled) return; + const trip = trips[0]; + if (!trip) { + setTrainNotRunning(true); + setResolvedTripId(null); + setTrainStops([]); + return; + } + setTrainNotRunning(false); + setResolvedTripId(trip.tripId); + const stops = await getTripStops(trip.tripId); + if (cancelled) return; + setTrainStops(stops.map(apiEnrichedStopTimeToLegacy)); + } catch (e) { + if (cancelled) return; + logger.warn('[Search] failed to resolve trip', e); + setTrainNotRunning(true); + setResolvedTripId(null); + setTrainStops([]); + } + })(); + return () => { cancelled = true; }; }, [selectedTrainNumber, selectedDate]); // --- Handlers --- @@ -449,37 +647,20 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp return undefined; }, [trainServiceInfo]); + // The picker is bounded by trainServiceInfo.{min,max}Date. We no longer + // mark individual non-service days as disabled (the API doesn't expose a + // per-day calendar) — selecting a non-service day surfaces the existing + // "train not running" empty state. const calendarMarkedDates = useMemo(() => { - const marks: Record = {}; - - if (selectedTrainNumber && trainServiceInfo && isDataLoaded) { - const trips = gtfsParser.getTripsByNumber(selectedTrainNumber); - if (trips.length > 0) { - const current = new Date(trainServiceInfo.minDate); - const end = trainServiceInfo.maxDate; - while (current <= end) { - const active = trips.some(trip => gtfsParser.isServiceActiveOnDate(trip.service_id, current)); - if (!active) { - marks[toDateString(current)] = { disabled: true, disabledColor: '#555555' }; - } - current.setDate(current.getDate() + 1); - } - } - } - + const marks: Record = {}; if (selectedDate) { const key = toDateString(selectedDate); - marks[key] = { ...marks[key], selected: true, selectedColor: '#FFFFFF' }; + marks[key] = { selected: true, selectedColor: '#FFFFFF' }; } - return marks; - }, [selectedTrainNumber, trainServiceInfo, isDataLoaded, selectedDate]); + }, [selectedDate]); const handleDayPress = (day: DateData) => { - // Don't allow selecting disabled (greyed-out) dates - const mark = calendarMarkedDates[day.dateString]; - if (mark?.disabled) return; - hapticSuccess(); const [y, m, d] = day.dateString.split('-').map(Number); setSelectedDate(new Date(y, m - 1, d)); @@ -497,21 +678,24 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp setExpandedRouteName(''); }; - const handleSelectRoute = (route: Route) => { + const handleSelectRoute = async (route: Route) => { hapticSelection(); - const trains = gtfsParser.getTrainNumbersForRoute(route.route_id); - if (trains.length === 1) { - // Single train on route -- go directly to train flow - handleSelectTrain(trains[0].trainNumber, trains[0].displayName); - } else { + try { + const code = bareCode(route.route_id); + const trains = (await getTrainsForRoute(PROVIDER_ID, code)).map(apiTrainItemToRouteTrainItem); + if (trains.length === 1) { + handleSelectTrain(trains[0].trainNumber, trains[0].displayName); + return; + } setExpandedRouteTrains(trains); setExpandedRouteName(route.route_long_name); setSearchQuery(''); // Fetch live train numbers for status indicators - RealtimeService.getAllActiveTrains().then(active => { - const nums = new Set(active.map(t => t.trainNumber)); - setLiveTrainNumbers(nums); - }).catch(e => logger.warn('Failed to fetch active trains', e)); + getActiveTrains(PROVIDER_ID) + .then(active => setLiveTrainNumbers(new Set(active.map(t => t.trainNumber)))) + .catch(e => logger.warn('Failed to fetch active trains', e)); + } catch (e) { + logger.warn('[Search] failed to load route trains', e); } }; @@ -557,7 +741,7 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp setExpandedRouteName(''); }} searchInputRef={searchInputRef} - isDataLoaded={isDataLoaded} + isDataLoaded={true} showDatePicker={showDatePicker} unifiedResults={unifiedResults} hasUnifiedResults={hasUnifiedResults} @@ -572,8 +756,8 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp onTodayTripPress={() => { if (!todayTrain) return; hapticSelection(); - const from = gtfsParser.getStop(todayTrain.fromCode); - const to = gtfsParser.getStop(todayTrain.toCode); + const from = lookupStop(todayTrain.fromCode); + const to = lookupStop(todayTrain.toCode); if (from && to) { setFromStation(from); setToStation(to); @@ -585,8 +769,10 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp if (suggestion.type === 'train' && suggestion.trainNumber) { handleSelectTrain(suggestion.trainNumber, suggestion.displayName || suggestion.label); } else if (suggestion.type === 'route' && suggestion.routeId) { - const route = gtfsParser.getRoute(suggestion.routeId); - if (route) handleSelectRoute(route); + const code = bareCode(suggestion.routeId); + getRoute(PROVIDER_ID, code) + .then(r => handleSelectRoute(apiRouteToLegacy(r))) + .catch(e => logger.warn('Failed to load route', e)); } else if (suggestion.stop && suggestion.toStop) { hapticSelection(); setFromStation(suggestion.stop); @@ -664,7 +850,7 @@ export function TwoStationSearch({ onSelectTrip, onClose }: TwoStationSearchProp searchQuery={searchQuery} setSearchQuery={setSearchQuery} searchInputRef={searchInputRef} - isDataLoaded={isDataLoaded} + isDataLoaded={true} showDatePicker={showDatePicker} stationResults={stationResults} tripResults={tripResults} diff --git a/apps/mobile/components/ui/DepartureBoardModal.tsx b/apps/mobile/components/ui/DepartureBoardModal.tsx index 0943083..788af99 100644 --- a/apps/mobile/components/ui/DepartureBoardModal.tsx +++ b/apps/mobile/components/ui/DepartureBoardModal.tsx @@ -28,7 +28,8 @@ import TrainCardContent from '../TrainCardContent'; import MarqueeText from './MarqueeText'; import { SkeletonBox } from './SkeletonBox'; import { getCurrentMinutesInTimezone, getCurrentSecondsInTimezone, getTimezoneForStop } from '../../utils/timezone'; -import { gtfsParser } from '../../utils/gtfs-parser'; +import { lookupAgencyTimezone, lookupStop } from '../../utils/api-stop-cache'; +import { useApiCacheVersion } from '../../hooks/useApiCache'; import { formatTemp, weatherApiTempUnit } from '../../utils/units'; import { getWeatherCondition } from '../../utils/weather'; import { SlideUpModalContext } from './SlideUpModal'; @@ -105,7 +106,7 @@ function isTrainUpcoming( } // Times are now in the station's local timezone, so compare "now" in that timezone - const currentMinutes = getCurrentMinutesInTimezone(stationTimezone ?? gtfsParser.agencyTimezone); + const currentMinutes = getCurrentMinutesInTimezone(stationTimezone ?? lookupAgencyTimezone()); let relevantTime: string; if (filterMode === 'arriving' || train.toCode === stationId) { @@ -190,13 +191,14 @@ const DepartureItem = React.memo(function DepartureItem({ train, stationTime, st const { colors } = useTheme(); const trainCardStyles = useMemo(() => createTrainCardStyles(colors), [colors]); const departStyles = useMemo(() => createDepartureStyles(colors), [colors]); + const cacheVersion = useApiCacheVersion(); const depDelay = train.realtime?.delay; const countdown = useMemo(() => { // Times are in the station's local timezone; compare "now" in same tz - const stopData = gtfsParser.getStop(stationId); - const tz = stopData ? getTimezoneForStop(stopData) : gtfsParser.agencyTimezone; + const stopData = lookupStop(stationId); + const tz = stopData ? getTimezoneForStop(stopData) : lookupAgencyTimezone(); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -224,7 +226,7 @@ const DepartureItem = React.memo(function DepartureItem({ train, stationTime, st const seconds = Math.round(absSec); if (seconds >= 60) return { value: 1, unit: 'MINUTE', past }; return { value: seconds, unit: seconds === 1 ? 'SECOND' : 'SECONDS', past }; - }, [stationTime, selectedDate, stationId, depDelay]); + }, [stationTime, selectedDate, stationId, depDelay, cacheVersion]); const countdownLabel = countdown.unit; const arrDelay = train.realtime?.arrivalDelay; diff --git a/apps/mobile/components/ui/TrainDetailModal.tsx b/apps/mobile/components/ui/TrainDetailModal.tsx index 5328a06..da6bfdb 100644 --- a/apps/mobile/components/ui/TrainDetailModal.tsx +++ b/apps/mobile/components/ui/TrainDetailModal.tsx @@ -10,7 +10,6 @@ import { useTheme } from '../../context/ThemeContext'; import { light as hapticLight } from '../../utils/haptics'; import { isThruwayName, TrainIcon } from '../TrainIcon'; import { addDelayToTime, formatDelayStatus, formatTimeWithDayOffset, getDelayColorKey, parseTimeToMinutes, timeToMinutes } from '../../utils/time-formatting'; -import { RealtimeService } from '../../services/realtime'; import { fetchWithTimeout } from '../../utils/fetch-with-timeout'; import { useTrainContext } from '../../context/TrainContext'; @@ -18,7 +17,9 @@ import { useUnits } from '../../context/UnitsContext'; import { TrainStorageService } from '../../services/storage'; import type { Train } from '../../types/train'; import { haversineDistance } from '../../utils/distance'; -import { gtfsParser } from '../../utils/gtfs-parser'; +import { lookupAgencyTimezone, lookupStop } from '../../utils/api-stop-cache'; +import { useApiCacheVersion } from '../../hooks/useApiCache'; +import { useTripDetail } from '../../hooks/useTripDetail'; import { logger, openReportBadDataEmail } from '../../utils/logger'; import { convertGtfsTimeToLocal, getCurrentMinutesInTimezone, getCurrentSecondsInTimezone, getTimezoneForStop } from '../../utils/timezone'; import { calculateDuration, getCountdownForTrain, pluralize, pluralCount } from '../../utils/train-display'; @@ -122,7 +123,6 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const { tempUnit, distanceUnit } = useUnits(); const trainData = train || selectedTrain; - const [allStops, setAllStops] = React.useState([]); const [isWhereIsMyTrainExpanded, setIsWhereIsMyTrainExpanded] = React.useState(true); const [weatherData, setWeatherData] = React.useState(null); @@ -131,56 +131,55 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const [error, setError] = React.useState(null); const [stopWeather, setStopWeather] = React.useState>({}); const stopWeatherKeyRef = React.useRef(null); - const [stopDelays, setStopDelays] = React.useState>(new Map()); const isLiveTrain = trainData?.realtime?.position !== undefined; - // Load stops from GTFS — only re-run when the trip actually changes - const tripId = trainData?.tripId; - React.useEffect(() => { - if (!tripId) return; + // Subscribe to api-client cache updates so lookupStop / lookupAgencyTimezone + // re-render this component when fetches land. + useApiCacheVersion(); - try { - const stops = gtfsParser.getStopTimesForTrip(tripId); - if (stops && stops.length > 0) { - const agencyTzVal = gtfsParser.agencyTimezone; - const formattedStops = stops.map(stop => { - const stopData = gtfsParser.getStop(stop.stop_id); - const stopTz = stopData ? getTimezoneForStop(stopData) : agencyTzVal; - const formatted = stop.departure_time - ? convertGtfsTimeToLocal(stop.departure_time, agencyTzVal, stopTz) - : { time: '', dayOffset: 0 }; - return { - time: formatted.time, - dayOffset: formatted.dayOffset, - name: stop.stop_name, - code: stop.stop_id, - timezone: stopTz || agencyTzVal, - }; - }); - setAllStops(formattedStops); - } - } catch (e) { - logger.error('Failed to load stops:', e); - } - }, [tripId]); + const tripId = trainData?.tripId; + const runDate = React.useMemo( + () => (trainData?.travelDate ? new Date(trainData.travelDate) : new Date()), + [trainData?.travelDate], + ); + const tripDetail = useTripDetail(tripId, runDate); + const agencyTz = lookupAgencyTimezone(); + + const allStops = React.useMemo(() => { + if (tripDetail.stops.length === 0) return []; + return tripDetail.stops.map(s => { + const stopTz = s.timezone || agencyTz; + const gtfsTime = s.scheduledDeparture || s.scheduledArrival || ''; + const formatted = gtfsTime + ? convertGtfsTimeToLocal(gtfsTime, agencyTz, stopTz) + : { time: '', dayOffset: 0 }; + return { + time: formatted.time, + dayOffset: formatted.dayOffset, + name: s.name, + code: s.code, + timezone: stopTz, + }; + }); + }, [tripDetail.stops, agencyTz]); - // Fetch per-stop delays for the timeline — re-run when realtime delay changes + // Per-stop delays come from /v1/runs/{provider}/{tripId}/{runDate}/stops via + // useTripDetail. Until that endpoint lands the map is empty (the timeline + // still renders scheduled times — just without "+5 min" badges). const daysAway = trainData?.daysAway; - const currentDelay = trainData?.realtime?.delay; - React.useEffect(() => { - if (!tripId || (daysAway != null && daysAway > 0)) { - setStopDelays(new Map()); - return; + const stopDelays = React.useMemo(() => { + const m = new Map(); + if (daysAway != null && daysAway > 0) return m; + for (const s of tripDetail.stops) { + if (s.arrivalDelayMin == null && s.departureDelayMin == null) continue; + m.set(s.code, { + departureDelay: s.departureDelayMin ?? undefined, + arrivalDelay: s.arrivalDelayMin ?? undefined, + }); } - let cancelled = false; - const fetchDelays = async () => { - const delays = await RealtimeService.getDelaysForAllStops(tripId); - if (!cancelled) setStopDelays(delays); - }; - fetchDelays(); - return () => { cancelled = true; }; - }, [tripId, daysAway, currentDelay]); + return m; + }, [tripDetail.stops, daysAway]); // Fetch weather data for destination — only when destination or unit changes const toCode = trainData?.toCode; @@ -191,7 +190,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const fetchWeather = async () => { try { setIsLoadingWeather(true); - const destStop = gtfsParser.getStop(toCode); + const destStop = lookupStop(toCode); if (!destStop) return; const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${destStop.stop_lat}&longitude=${destStop.stop_lon}¤t=temperature_2m,weather_code&temperature_unit=${weatherApiTempUnit(tempUnit)}&timezone=auto`; @@ -236,7 +235,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr const promises = allStops.map(async (stop) => { try { - const stopData = gtfsParser.getStop(stop.code); + const stopData = lookupStop(stop.code); if (!stopData) return; const targetDate = new Date(baseDate); @@ -306,8 +305,8 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr if (!trainData || allStops.length === 0) return null; try { - const originStop = gtfsParser.getStop(trainData.fromCode); - const destStop = gtfsParser.getStop(trainData.toCode); + const originStop = lookupStop(trainData.fromCode); + const destStop = lookupStop(trainData.toCode); logger.debug('Timezone: origin stop lookup', { fromCode: trainData.fromCode, @@ -409,8 +408,8 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr let distanceMiles: number | null = null; if (trainData) { try { - const fromStop = gtfsParser.getStop(trainData.fromCode); - const toStop = gtfsParser.getStop(trainData.toCode); + const fromStop = lookupStop(trainData.fromCode); + const toStop = lookupStop(trainData.toCode); if (fromStop && toStop) { distanceMiles = haversineDistance(fromStop.stop_lat, fromStop.stop_lon, toStop.stop_lat, toStop.stop_lon); } @@ -424,7 +423,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr if (!onStationSelect) return; hapticLight(); try { - const stop = gtfsParser.getStop(stationCode); + const stop = lookupStop(stationCode); if (stop) { onStationSelect(stationCode, stop.stop_lat, stop.stop_lon); } @@ -435,7 +434,6 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr // Find next stop for live trains // Each stop's time is now in that stop's local timezone - const agencyTz = gtfsParser.agencyTimezone; const nextStopIndex = React.useMemo(() => { if (!isLiveTrain || allStops.length === 0) return -1; @@ -463,7 +461,7 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr if (!isLiveTrain || nextStopIndex < 0 || nextStopIndex >= allStops.length) return null; const stop = allStops[nextStopIndex]; if (!stop) return null; - const stopData = gtfsParser.getStop(stop.code); + const stopData = lookupStop(stop.code); return stopData ? getTimezoneForStop(stopData) : null; }, [isLiveTrain, nextStopIndex, allStops]); React.useEffect(() => { @@ -579,8 +577,8 @@ export default function TrainDetailModal({ train, onClose, onStationSelect, onTr : (liveDelayKey === 'onTime' || isLiveTrain) ? colors.success : colors.primary; // Check if the train has completed (arrival time has passed) - const destStop = gtfsParser.getStop(trainData.toCode); - const destTz = destStop ? getTimezoneForStop(destStop) : gtfsParser.agencyTimezone; + const destStop = lookupStop(trainData.toCode); + const destTz = destStop ? getTimezoneForStop(destStop) : agencyTz; const nowSec = getCurrentSecondsInTimezone(destTz); const arrivalDelay = trainData.realtime?.arrivalDelay; const arriveSec = parseTimeToMinutes(trainData.arriveTime) * 60 diff --git a/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx b/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx index 4131238..8afeeb9 100644 --- a/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx +++ b/apps/mobile/components/ui/train-detail/DepartureArrivalBoard.tsx @@ -2,11 +2,12 @@ import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import { type ColorPalette, Spacing } from '../../../constants/theme'; +import { useApiCacheVersion } from '../../../hooks/useApiCache'; +import { lookupAgencyTimezone, lookupStop } from '../../../utils/api-stop-cache'; import { addDelayToTime, formatDelayStatus, getDelayColorKey, parseTimeToMinutes } from '../../../utils/time-formatting'; import { getCurrentSecondsInTimezone } from '../../../utils/timezone'; import { pluralCount } from '../../../utils/train-display'; import { convertDistance, distanceSuffix } from '../../../utils/units'; -import { gtfsParser } from '../../../utils/gtfs-parser'; import { getTimezoneForStop } from '../../../utils/timezone'; import { pluralize } from '../../../utils/train-display'; import AnimatedRollingText from '../AnimatedRollingText'; @@ -24,6 +25,10 @@ export default function DepartureArrivalBoard({ colors, handleStationPress, }: DepartureArrivalBoardProps) { + // Subscribe to api-client cache so the destination-stop tz lookup re-renders + // once the stop fetch lands. + useApiCacheVersion(); + return ( {/* Departure Info */} @@ -120,8 +125,8 @@ export default function DepartureArrivalBoard({ // Compute arrival countdown const arriveTime = aDelayed?.time || trainData.arriveTime; const arriveDayOffset = aDelayed?.dayOffset ?? (trainData.arriveDayOffset || 0); - const destStopData = gtfsParser.getStop(trainData.toCode); - const destTimezone = destStopData ? getTimezoneForStop(destStopData) : gtfsParser.agencyTimezone; + const destStopData = lookupStop(trainData.toCode); + const destTimezone = destStopData ? getTimezoneForStop(destStopData) : lookupAgencyTimezone(); const nowSec = getCurrentSecondsInTimezone(destTimezone); const arriveSec = parseTimeToMinutes(arriveTime) * 60 + arriveDayOffset * 24 * 3600; diff --git a/apps/mobile/constants/config.ts b/apps/mobile/constants/config.ts index d4b14f9..6eac59d 100644 --- a/apps/mobile/constants/config.ts +++ b/apps/mobile/constants/config.ts @@ -11,5 +11,5 @@ const extra = (Constants.expoConfig?.extra ?? {}) as AppExtra; export const config = { apiUrl: extra.apiUrl ?? 'https://api.trackyapp.net', wsUrl: extra.wsUrl ?? 'wss://api.trackyapp.net/ws/realtime', - tilesUrl: extra.tilesUrl ?? 'https://tiles.trytracky.com', + tilesUrl: extra.tilesUrl ?? 'https://tiles.trackyapp.net', }; diff --git a/apps/mobile/context/GTFSRefreshContext.tsx b/apps/mobile/context/GTFSRefreshContext.tsx index c0e0e0a..7da55d3 100644 --- a/apps/mobile/context/GTFSRefreshContext.tsx +++ b/apps/mobile/context/GTFSRefreshContext.tsx @@ -1,29 +1,47 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +/** + * Vestigial GTFS-refresh context. + * + * Originally drove the local GTFS download/cache lifecycle on app start. + * The app now uses the Go API for static data, so there's nothing to + * refresh — but several callers (RefreshBubble, SettingsModal, MapScreen, + * ModalContent) still depend on the hook's shape. This stub keeps that + * shape stable while the call sites are cleaned up in a follow-up. + * + * Behaviors retained: + * - LocationSuggestionsService.initialize runs once on mount (it is + * independent of GTFS and the suggestions UI relies on it). + * - Native splash is hidden immediately on mount (no cache to load). + */ + import * as SplashScreen from 'expo-splash-screen'; -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert } from 'react-native'; -import { ensureFreshGTFS, isCacheStale, loadCachedGTFS, loadDeferredShapes } from '../services/gtfs-sync'; +import React, { createContext, useContext, useEffect, useMemo } from 'react'; import { LocationSuggestionsService } from '../services/location-suggestions'; import { logger } from '../utils/logger'; -interface GTFSRefreshState { +interface GTFSRefreshContextType { isRefreshing: boolean; isLoadingCache: boolean; isStreamingData: boolean; refreshProgress: number; refreshStep: string; refreshFailed: boolean; -} - -interface GTFSRefreshContextType extends GTFSRefreshState { - /** Manual refresh triggered from settings */ triggerRefresh: () => void; - /** Dismiss the persistent failure indicator */ dismissRefreshFailure: () => void; - /** Debug: show loading screen for 5 seconds */ debugShowLoadingScreen: () => void; } +const NOOP_VALUE: GTFSRefreshContextType = { + isRefreshing: false, + isLoadingCache: false, + isStreamingData: false, + refreshProgress: 0, + refreshStep: '', + refreshFailed: false, + triggerRefresh: () => {}, + dismissRefreshFailure: () => {}, + debugShowLoadingScreen: () => {}, +}; + const GTFSRefreshContext = createContext(undefined); export const useGTFSRefresh = () => { @@ -36,140 +54,15 @@ export const GTFSRefreshProvider: React.FC<{ children: React.ReactNode; onRefres children, onRefreshComplete, }) => { - const [isRefreshing, setIsRefreshing] = useState(false); - const [isLoadingCache, setIsLoadingCache] = useState(true); - const [refreshProgress, setRefreshProgress] = useState(0); - const [refreshStep, setRefreshStep] = useState(''); - const [refreshFailed, setRefreshFailed] = useState(false); - const [isStreamingData, setIsStreamingData] = useState(false); - const hasInitialized = useRef(false); - const onRefreshCompleteRef = useRef(onRefreshComplete); - onRefreshCompleteRef.current = onRefreshComplete; - - const runRefresh = useCallback(async (force: boolean) => { - logger.info(`[GTFS] Starting refresh (force=${force})`); - setIsRefreshing(true); - setRefreshProgress(0.05); - setRefreshStep(force ? 'Forcing refresh' : 'Checking schedule'); - try { - if (force) { - await AsyncStorage.removeItem('GTFS_LAST_FETCH'); - } - const result = await ensureFreshGTFS(update => { - setRefreshProgress(update.progress); - setRefreshStep(update.step + (update.detail ? ` · ${update.detail}` : '')); - }); - if (result.usedCache && !force) { - // Cache was still valid — no download needed - } - setRefreshProgress(1); - setRefreshStep('Refresh complete'); - setRefreshFailed(false); - logger.info(`[GTFS] Refresh complete (usedCache=${result.usedCache})`); - LocationSuggestionsService.initialize().catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); - onRefreshCompleteRef.current?.(); - // Brief display of completion then clear - setTimeout(() => { - setIsRefreshing(false); - setRefreshProgress(0); - setRefreshStep(''); - }, 1200); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`GTFS refresh failed: ${msg}`, error); - setRefreshStep(`Schedule update failed: ${msg}`); - setRefreshFailed(true); - setTimeout(() => { - setIsRefreshing(false); - setRefreshProgress(0); - }, 2000); - } - }, []); - - // Auto-initialize GTFS on provider mount (runs once) useEffect(() => { - if (hasInitialized.current) return; - hasInitialized.current = true; - - const initialize = async () => { - setIsLoadingCache(true); - setRefreshStep('Loading cached data...'); - setRefreshProgress(0.1); - - // Hide native splash immediately — the app's own LoadingOverlay handles the visual loading state - SplashScreen.hideAsync(); - - try { - const loaded = await loadCachedGTFS(); - if (loaded) { - // Cache loaded — app is usable now - setIsLoadingCache(false); - setRefreshProgress(0); - setRefreshStep(''); - - // Load shapes in background after splash is hidden - setIsStreamingData(true); - loadDeferredShapes().finally(() => setIsStreamingData(false)); - - // Pre-compute location-based suggestions in background - LocationSuggestionsService.initialize().catch(e => logger.warn('LocationSuggestionsService.initialize failed', e)); - - // Check staleness in background - const stale = await isCacheStale(); - if (stale) { - runRefresh(false); - } - } else { - // No cache at all — show refresh progress UI - setIsLoadingCache(false); - runRefresh(false); - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - logger.error(`GTFS initialization failed: ${msg}`, error); - setIsLoadingCache(false); - setRefreshStep(''); - Alert.alert( - 'Schedule Data Needs Refresh', - 'Cached schedule data could not be loaded. A fresh download is required.', - [{ text: 'Refresh Now', onPress: () => runRefresh(true) }] - ); - } - }; - - initialize(); - }, [runRefresh]); - - const triggerRefresh = useCallback(() => { - if (isRefreshing) return; - setRefreshFailed(false); - runRefresh(false); - }, [isRefreshing, runRefresh]); - - const dismissRefreshFailure = useCallback(() => { - setRefreshFailed(false); - setRefreshStep(''); - }, []); - - const debugShowLoadingScreen = useCallback(() => { - setIsLoadingCache(true); - setTimeout(() => setIsLoadingCache(false), 5000); - }, []); - - const value = useMemo( - () => ({ - isRefreshing, - isLoadingCache, - isStreamingData, - refreshProgress, - refreshStep, - refreshFailed, - triggerRefresh, - dismissRefreshFailure, - debugShowLoadingScreen, - }), - [isRefreshing, isLoadingCache, isStreamingData, refreshProgress, refreshStep, refreshFailed, triggerRefresh, dismissRefreshFailure, debugShowLoadingScreen] - ); + SplashScreen.hideAsync(); + LocationSuggestionsService.initialize().catch(e => + logger.warn('LocationSuggestionsService.initialize failed', e), + ); + onRefreshComplete?.(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const value = useMemo(() => NOOP_VALUE, []); return ( diff --git a/apps/mobile/hooks/useApiCache.ts b/apps/mobile/hooks/useApiCache.ts new file mode 100644 index 0000000..d3c46b5 --- /dev/null +++ b/apps/mobile/hooks/useApiCache.ts @@ -0,0 +1,64 @@ +/** + * Hooks that wrap synchronous reads of the api-client cache. Each one + * subscribes to cache-change notifications so a render fires when a fetch + * completes, while keeping the component code feeling synchronous. + * + * Use these instead of `await getStop(...)` etc. inside components — the + * hook fires a background fetch on first call and re-renders when data + * lands. This mirrors the ergonomic of the old gtfsParser.getStop() but + * is API-backed. + */ + +import { useEffect, useSyncExternalStore } from 'react'; +import { + getApiCacheVersion, + getCachedAgency, + getCachedRoute, + getCachedStop, + prefetchAgency, + prefetchRoute, + prefetchStop, + subscribeApiCache, +} from '../services/api-client'; +import type { ApiAgency, ApiRoute, ApiStop } from '../types/api'; + +/** + * Subscribe to api-client cache invalidations. Components that read via + * `lookupStop`/`lookupAgencyTimezone` (sync, api-stop-cache) should call + * this once so they re-render when new entries land. + */ +export function useApiCacheVersion(): number { + return useSyncExternalStore(subscribeApiCache, getApiCacheVersion, getApiCacheVersion); +} + +function useCacheVersion(): number { + return useApiCacheVersion(); +} + +const DEFAULT_PROVIDER = 'amtrak'; + +export function useStop(stopCode: string | null | undefined, providerId: string = DEFAULT_PROVIDER): ApiStop | undefined { + useCacheVersion(); + useEffect(() => { + if (stopCode) prefetchStop(providerId, stopCode); + }, [providerId, stopCode]); + if (!stopCode) return undefined; + return getCachedStop(providerId, stopCode); +} + +export function useRoute(routeId: string | null | undefined): ApiRoute | undefined { + useCacheVersion(); + useEffect(() => { + if (routeId) prefetchRoute(routeId); + }, [routeId]); + if (!routeId) return undefined; + return getCachedRoute(routeId); +} + +export function useAgency(providerId: string = DEFAULT_PROVIDER): ApiAgency | undefined { + useCacheVersion(); + useEffect(() => { + prefetchAgency(providerId); + }, [providerId]); + return getCachedAgency(providerId); +} diff --git a/apps/mobile/hooks/useShapes.ts b/apps/mobile/hooks/useShapes.ts deleted file mode 100644 index da9be5a..0000000 --- a/apps/mobile/hooks/useShapes.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { shapeLoader, type VisibleShape } from '../services/shape-loader'; -import type { ViewportBounds } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { debug } from '../utils/logger'; - -export function useShapes(bounds?: ViewportBounds) { - const [gtfsLoaded, setGtfsLoaded] = useState(gtfsParser.isLoaded); - const [shapesVersion, setShapesVersion] = useState(0); - - // Subscribe to GTFS loaded event — no polling - useEffect(() => { - if (gtfsLoaded) return; - return gtfsParser.onLoaded(() => setGtfsLoaded(true)); - }, [gtfsLoaded]); - - // Subscribe to deferred shapes update - useEffect(() => { - return gtfsParser.onShapesUpdated(() => setShapesVersion(v => v + 1)); - }, []); - - // Compute visible shapes for the current bounds - const visibleShapes = useMemo(() => { - if (!gtfsLoaded) return []; - let shapes: VisibleShape[]; - if (bounds) { - shapes = shapeLoader.getVisibleShapes(bounds); - } else { - shapes = shapeLoader.getAllShapes(); - } - debug(`[useShapes] ${shapes.length} shapes visible in viewport`); - return shapes; - }, [gtfsLoaded, shapesVersion, bounds?.minLat, bounds?.maxLat, bounds?.minLon, bounds?.maxLon]); - - return { visibleShapes }; -} diff --git a/apps/mobile/hooks/useStations.ts b/apps/mobile/hooks/useStations.ts deleted file mode 100644 index 32657b8..0000000 --- a/apps/mobile/hooks/useStations.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import type { VisibleStation } from '../services/station-loader'; -import { stationLoader } from '../services/station-loader'; -import type { ViewportBounds } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { debug } from '../utils/logger'; - -export function useStations(bounds?: ViewportBounds) { - const [gtfsLoaded, setGtfsLoaded] = useState(gtfsParser.isLoaded); - const [initialized, setInitialized] = useState(false); - - // Subscribe to GTFS loaded event — no polling - useEffect(() => { - if (gtfsLoaded) return; - return gtfsParser.onLoaded(() => setGtfsLoaded(true)); - }, [gtfsLoaded]); - - // Initialize station loader once GTFS is loaded - useEffect(() => { - if (!gtfsLoaded || initialized) return; - - const stops = gtfsParser.getAllStops(); - stationLoader.initialize(stops); - debug(`[useStations] Initialized station loader with ${stops.length} stops`); - setInitialized(true); - }, [gtfsLoaded, initialized]); - - // Get visible stations based on bounds - const stations = useMemo(() => { - if (!initialized) return []; - - if (bounds) { - // Use padding for smoother panning - return stationLoader.getVisibleStations(bounds); - } - - // No bounds - return all stations (fallback) - const stops = gtfsParser.getAllStops(); - return stops.map(s => ({ - id: s.stop_id, - name: s.stop_name, - lat: s.stop_lat, - lon: s.stop_lon, - })); - }, [initialized, bounds?.minLat, bounds?.maxLat, bounds?.minLon, bounds?.maxLon]); - - return stations; -} diff --git a/apps/mobile/hooks/useTravelOverlay.ts b/apps/mobile/hooks/useTravelOverlay.ts index f910c63..7e1365d 100644 --- a/apps/mobile/hooks/useTravelOverlay.ts +++ b/apps/mobile/hooks/useTravelOverlay.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { getStop } from '../services/api-client'; import { TrainStorageService } from '../services/storage'; -import { gtfsParser } from '../utils/gtfs-parser'; + +const DEFAULT_PROVIDER = 'amtrak'; interface TravelLine { key: string; @@ -14,6 +16,22 @@ interface TravelStation { id: string; } +function splitNamespaced(stopId: string): { provider: string; code: string } { + const i = stopId.indexOf(':'); + if (i <= 0) return { provider: DEFAULT_PROVIDER, code: stopId }; + return { provider: stopId.slice(0, i), code: stopId.slice(i + 1) }; +} + +async function resolveCoord(stopId: string): Promise<{ latitude: number; longitude: number } | null> { + try { + const { provider, code } = splitNamespaced(stopId); + const s = await getStop(provider, code); + return { latitude: s.lat, longitude: s.lon }; + } catch { + return null; + } +} + /** * Manages travel overlay data for profile/settings views. * Loads trip history and resolves coordinates for travel lines/stations. @@ -23,27 +41,41 @@ export function useTravelOverlay(isOverlayMode: boolean) { const [travelStations, setTravelStations] = useState([]); const [profileYear, setProfileYear] = useState(null); const tripHistoryRef = useRef>>([]); + const resolveTokenRef = useRef(0); - const resolveTravelOverlay = useCallback((history: typeof tripHistoryRef.current, year: number | null) => { - const lines: TravelLine[] = []; - const stationMap = new Map(); + const resolveTravelOverlay = useCallback(async ( + history: typeof tripHistoryRef.current, + year: number | null, + ) => { + const token = ++resolveTokenRef.current; + const filtered = year ? history.filter(t => new Date(t.travelDate).getFullYear() === year) : history; - for (const trip of history) { - if (year && new Date(trip.travelDate).getFullYear() !== year) continue; - - const fromStop = gtfsParser.getStop(trip.fromCode); - const toStop = gtfsParser.getStop(trip.toCode); - if (!fromStop || !toStop) continue; + const codes = new Set(); + for (const trip of filtered) { + codes.add(trip.fromCode); + codes.add(trip.toCode); + } + const codeList = [...codes]; + const coords = await Promise.all(codeList.map(resolveCoord)); + if (token !== resolveTokenRef.current) return; + const coordByCode = new Map(); + for (let i = 0; i < codeList.length; i++) { + const c = coords[i]; + if (c) coordByCode.set(codeList[i], c); + } - const fromCoord = { latitude: fromStop.stop_lat, longitude: fromStop.stop_lon }; - const toCoord = { latitude: toStop.stop_lat, longitude: toStop.stop_lon }; + const lines: TravelLine[] = []; + const stationMap = new Map(); + for (const trip of filtered) { + const fromCoord = coordByCode.get(trip.fromCode); + const toCoord = coordByCode.get(trip.toCode); + if (!fromCoord || !toCoord) continue; lines.push({ key: `${trip.tripId}-${trip.fromCode}-${trip.toCode}-${trip.travelDate}`, from: fromCoord, to: toCoord, }); - if (!stationMap.has(trip.fromCode)) stationMap.set(trip.fromCode, fromCoord); if (!stationMap.has(trip.toCode)) stationMap.set(trip.toCode, toCoord); } @@ -55,6 +87,7 @@ export function useTravelOverlay(isOverlayMode: boolean) { // Load trip history when overlay mode activates useEffect(() => { if (!isOverlayMode) { + resolveTokenRef.current++; setTravelLines([]); setTravelStations([]); setProfileYear(null); @@ -65,13 +98,13 @@ export function useTravelOverlay(isOverlayMode: boolean) { (async () => { const history = await TrainStorageService.getTripHistory(); tripHistoryRef.current = history; - resolveTravelOverlay(history, profileYear); + await resolveTravelOverlay(history, profileYear); })(); }, [isOverlayMode]); // eslint-disable-line react-hooks/exhaustive-deps const handleProfileYearChange = useCallback((year: number | null) => { setProfileYear(year); - resolveTravelOverlay(tripHistoryRef.current, year); + void resolveTravelOverlay(tripHistoryRef.current, year); }, [resolveTravelOverlay]); return { diff --git a/apps/mobile/hooks/useTripDetail.ts b/apps/mobile/hooks/useTripDetail.ts new file mode 100644 index 0000000..4140ec7 --- /dev/null +++ b/apps/mobile/hooks/useTripDetail.ts @@ -0,0 +1,183 @@ +/** + * Loads everything TrainDetailModal needs about a trip: scheduled stop + * times, per-stop coordinates/timezone, and (when available) live + * estimated/actual times per stop. + * + * Replaces several gtfsParser/RealtimeService calls with one async hook. + * Stop coordinates fan out as parallel /v1/stops/{provider}/{stopCode} + * requests; the 1h cache makes repeat opens of the same trip cheap. + * + * Per-stop ETAs come from /v1/runs/{provider}/{tripId}/{runDate}/stops + * which is currently a stub on apps/api — the hook returns scheduled-only + * times until the backend lands that endpoint. + */ + +import { useEffect, useState } from 'react'; +import { ApiError, getRunStops, getStop, getTripStops } from '../services/api-client'; +import type { ApiTrainStopTime } from '../types/api'; +import { logger } from '../utils/logger'; + +export interface TripDetailStop { + /** Namespaced provider:code, e.g. "amtrak:NYP". */ + stopId: string; + /** Raw GTFS stop_code, e.g. "NYP". */ + code: string; + name: string; + sequence: number; + /** GTFS-format scheduled times (HH:MM:SS, may be > 24:00 for overnight). */ + scheduledArrival: string | null; + scheduledDeparture: string | null; + /** Coordinates and timezone — populated by the per-stop fetch. */ + lat: number | null; + lon: number | null; + timezone: string | null; + /** Live data — only set when /v1/runs/.../stops returns rows for the run. */ + estimatedArrival: Date | null; + estimatedDeparture: Date | null; + actualArrival: Date | null; + actualDeparture: Date | null; + /** Minutes late (positive) or early (negative); null if not computable. */ + arrivalDelayMin: number | null; + departureDelayMin: number | null; +} + +export interface TripDetail { + stops: TripDetailStop[]; + loading: boolean; + /** True iff the per-stop ETA endpoint is unavailable for this run. */ + delaysUnavailable: boolean; +} + +const EMPTY: TripDetail = { stops: [], loading: false, delaysUnavailable: true }; + +function parseIso(s: string | null): Date | null { + if (!s) return null; + const d = new Date(s); + return Number.isFinite(d.getTime()) ? d : null; +} + +export function useTripDetail( + tripId: string | null | undefined, + runDate?: Date, +): TripDetail { + const [state, setState] = useState(EMPTY); + + useEffect(() => { + if (!tripId) { + setState(EMPTY); + return; + } + + let cancelled = false; + setState(s => ({ ...s, loading: true })); + + (async () => { + const provider = tripId.includes(':') ? tripId.split(':', 1)[0] : 'amtrak'; + + // Phase 1: scheduled stop times — single endpoint. + let scheduled: TripDetailStop[]; + try { + const apiStops = await getTripStops(tripId); + scheduled = apiStops.map(et => ({ + stopId: `${provider}:${et.stopCode}`, + code: et.stopCode, + name: et.stopName, + sequence: et.stopSequence, + scheduledArrival: et.arrivalTime, + scheduledDeparture: et.departureTime, + lat: null, + lon: null, + timezone: null, + estimatedArrival: null, + estimatedDeparture: null, + actualArrival: null, + actualDeparture: null, + arrivalDelayMin: null, + departureDelayMin: null, + })); + } catch (err) { + logger.warn('[useTripDetail] /v1/trips/.../stops failed', err); + if (!cancelled) setState({ stops: [], loading: false, delaysUnavailable: true }); + return; + } + if (cancelled) return; + + // Surface scheduled times immediately so the timeline can render. + setState({ stops: scheduled, loading: true, delaysUnavailable: true }); + + // Phase 2: per-stop coordinates / tz, fanned out in parallel. + const enrichedPromise = Promise.all( + scheduled.map(async (stop) => { + try { + const apiStop = await getStop(provider, stop.code); + return { + ...stop, + lat: apiStop.lat, + lon: apiStop.lon, + timezone: apiStop.timezone ?? null, + }; + } catch { + return stop; + } + }), + ); + + // Phase 3: per-stop estimated/actual times (optional — stub today). + const date = runDate ?? new Date(); + const ymd = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const delaysPromise = (async (): Promise<{ rows: ApiTrainStopTime[]; available: boolean }> => { + try { + const rows = await getRunStops({ provider, tripId, runDate: ymd }); + return { rows, available: true }; + } catch (err) { + if (!(err instanceof ApiError && err.status === 404)) { + logger.debug('[useTripDetail] run stops unavailable', err); + } + return { rows: [], available: false }; + } + })(); + + const [enriched, delays] = await Promise.all([enrichedPromise, delaysPromise]); + if (cancelled) return; + + const byCode = new Map(); + for (const r of delays.rows) byCode.set(r.stopCode, r); + + const merged: TripDetailStop[] = enriched.map(stop => { + const live = byCode.get(stop.code); + if (!live) return stop; + const scheduledArr = parseIso(live.scheduledArr); + const scheduledDep = parseIso(live.scheduledDep); + const estimatedArr = parseIso(live.estimatedArr); + const estimatedDep = parseIso(live.estimatedDep); + const actualArr = parseIso(live.actualArr); + const actualDep = parseIso(live.actualDep); + const refArr = actualArr ?? estimatedArr; + const refDep = actualDep ?? estimatedDep; + return { + ...stop, + estimatedArrival: estimatedArr, + estimatedDeparture: estimatedDep, + actualArrival: actualArr, + actualDeparture: actualDep, + arrivalDelayMin: + scheduledArr && refArr + ? Math.round((refArr.getTime() - scheduledArr.getTime()) / 60_000) + : null, + departureDelayMin: + scheduledDep && refDep + ? Math.round((refDep.getTime() - scheduledDep.getTime()) / 60_000) + : null, + }; + }); + + setState({ stops: merged, loading: false, delaysUnavailable: !delays.available }); + })(); + + return () => { + cancelled = true; + }; + }, [tripId, runDate?.getTime()]); + + return state; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4bd33e9..04e1a91 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -42,8 +42,6 @@ "expo-task-manager": "~55.0.9", "expo-web-browser": "~55.0.9", "expo-widgets": "55.0.4", - "fflate": "^0.8.2", - "gtfs-realtime-bindings": "^1.1.1", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.2", diff --git a/apps/mobile/screens/MapScreen.tsx b/apps/mobile/screens/MapScreen.tsx index 4192341..173a886 100644 --- a/apps/mobile/screens/MapScreen.tsx +++ b/apps/mobile/screens/MapScreen.tsx @@ -44,7 +44,6 @@ import { TrainAPIService } from '../services/api'; import { requestPermissions as requestNotificationPermissions } from '../services/notifications'; import { TrainStorageService } from '../services/storage'; import type { SavedTrainRef, Stop, Train, ViewportBounds } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; import { light as hapticLight } from '../utils/haptics'; import { logger } from '../utils/logger'; import { clusterTrains, type TrainCluster } from '../utils/train-clustering'; diff --git a/apps/mobile/services/api-client.ts b/apps/mobile/services/api-client.ts index 9e7935b..14911dc 100644 --- a/apps/mobile/services/api-client.ts +++ b/apps/mobile/services/api-client.ts @@ -11,6 +11,7 @@ import { config } from '../constants/config'; import { fetchWithTimeout } from '../utils/fetch-with-timeout'; import type { + ApiActiveTrain, ApiAgency, ApiConnectionItem, ApiDepartureItem, @@ -21,7 +22,6 @@ import type { ApiServiceInfo, ApiStop, ApiTrainItem, - ApiTrainPosition, ApiTrainStopTime, ApiTrip, } from '../types/api'; @@ -125,6 +125,14 @@ export function getRoute(providerId: string, routeCode: string): Promise { + const qs = buildQuery({ provider: providerId }); + return request(`/v1/routes${qs}`, { + cacheKey: `routes:${providerId}`, + ttlMs: STATIC_TTL_MS, + }); +} + export function getTrainsForRoute( providerId: string, routeCode: string, @@ -214,57 +222,54 @@ export function search(params: { return request(`/v1/search${qs}`); } -// ── Pending backend additions ────────────────────────────────────────────── -// -// The endpoints below aren't on the build-backend branch yet. They're stubbed -// here so call sites can be written; flip them on once the backend lands. +// ── Realtime ─────────────────────────────────────────────────────────────── /** * Per-stop scheduled + estimated + actual times for a specific run of a trip. - * Required for TrainDetailModal's per-stop delay display. - * - * Expected: GET /v1/runs/{provider}/{tripId}/{runDate}/stops + * Powers TrainDetailModal's per-stop delay timeline. Uncached — realtime data. */ -export function getRunStops(_params: { +export function getRunStops(params: { provider: string; tripId: string; runDate: string; }): Promise { - return Promise.reject( - new Error('getRunStops: backend endpoint not yet implemented'), + const { provider, tripId, runDate } = params; + return request( + `/v1/runs/${encodeURIComponent(provider)}/${encodeURIComponent(tripId)}/${encodeURIComponent(runDate)}/stops`, ); } /** - * Stops near a location across all providers, for "nearest station" suggestions. - * - * Expected: GET /v1/stops/nearby?lat=&lon=&radius_m= + * Currently-tracked runs for a provider, sourced from the latest realtime + * snapshot the backend has published. Used by the "live only" filter in the + * trip search; cheap enough to call on demand without caching. */ -export function getNearbyStops(_params: { - lat: number; - lon: number; - radiusMeters?: number; -}): Promise { - return Promise.reject( - new Error('getNearbyStops: backend endpoint not yet implemented'), +export function getActiveTrains(provider: string): Promise { + const qs = buildQuery({ provider }); + return request<{ activeTrains: ApiActiveTrain[] }>(`/v1/active${qs}`).then( + r => r.activeTrains, ); } /** - * Current TrainPosition for a specific run, for hydrating saved trains - * without subscribing to the full WS feed. (Optional — workaround is to - * subscribe + filter.) - * - * Expected: GET /v1/runs/{provider}/{tripId}/{runDate} + * Stops near a location across all providers, for "nearest station" suggestions. */ -export function getRunPosition(_params: { - provider: string; - tripId: string; - runDate: string; -}): Promise { - return Promise.reject( - new Error('getRunPosition: backend endpoint not yet implemented'), - ); +export function getNearbyStops(params: { + lat: number; + lon: number; + radiusMeters?: number; + provider?: string; +}): Promise { + const qs = buildQuery({ + lat: params.lat, + lon: params.lon, + radius_m: params.radiusMeters, + provider: params.provider, + }); + return request(`/v1/stops/nearby${qs}`, { + cacheKey: `nearby:${params.lat}:${params.lon}:${params.radiusMeters ?? ''}:${params.provider ?? ''}`, + ttlMs: STATIC_TTL_MS, + }); } // ── Cache utilities (mostly for tests / sign-out) ────────────────────────── @@ -293,8 +298,65 @@ export function prefetchRoute(routeId: string): void { if (sep <= 0) return; const provider = routeId.slice(0, sep); const code = routeId.slice(sep + 1); - // Swallow errors — prefetching is best-effort. - getRoute(provider, code).catch(() => { - /* leave it un-cached so a later attempt can retry */ + notifyAfter(getRoute(provider, code)); +} + +export function getCachedStop(providerId: string, stopCode: string): ApiStop | undefined { + return getCached(`stop:${providerId}:${stopCode}`); +} + +/** + * Synchronously read a previously-fetched trip from the in-memory cache. + */ +export function getCachedTrip(tripId: string): ApiTrip | undefined { + return getCached(`trip:${tripId}`); +} + +/** + * Fire-and-forget prefetch of trip metadata. + */ +export function prefetchTrip(tripId: string): void { + if (getCached(`trip:${tripId}`) !== undefined) return; + notifyAfter(getTrip(tripId)); +} + +export function prefetchStop(providerId: string, stopCode: string): void { + if (getCached(`stop:${providerId}:${stopCode}`) !== undefined) return; + notifyAfter(getStop(providerId, stopCode)); +} + +export function getCachedAgency(providerId: string): ApiAgency | undefined { + return getCached(`provider:${providerId}`); +} + +export function prefetchAgency(providerId: string): void { + if (getCached(`provider:${providerId}`) !== undefined) return; + notifyAfter(getProvider(providerId)); +} + +// ── Cache change notifications ───────────────────────────────────────────── +// +// Components that read from the sync getters above can subscribe here to be +// notified when a new value lands. Internal — used by the useApiCacheVersion +// hook (hooks/useApiCache.ts). + +const cacheListeners = new Set<() => void>(); +let cacheVersion = 0; + +export function subscribeApiCache(listener: () => void): () => void { + cacheListeners.add(listener); + return () => cacheListeners.delete(listener); +} + +export function getApiCacheVersion(): number { + return cacheVersion; +} + +function notifyAfter(p: Promise): void { + p.then(() => { + cacheVersion += 1; + for (const l of cacheListeners) l(); + }).catch(() => { + // leave un-cached so a later attempt can retry }); } diff --git a/apps/mobile/services/calendar-sync.ts b/apps/mobile/services/calendar-sync.ts index c25d80d..b4434e0 100644 --- a/apps/mobile/services/calendar-sync.ts +++ b/apps/mobile/services/calendar-sync.ts @@ -1,19 +1,20 @@ /** - * Calendar sync service for importing trips from device calendars. - * Scans for events like "Train to Philadelphia" and matches them against GTFS data. + * Calendar sync — DISABLED during the GTFS → API migration. + * + * The original matching loop relied on local stop_times/calendar joins + * (gtfsParser.findTripsWithStops, getTripsForStop, getStopTimesForTrip, + * searchStations, etc.). The API equivalents (/v1/connections, + * /v1/departures, /v1/search) cover most of it but the port hasn't been + * done yet. Re-enable in a follow-up — see git history for the original + * implementation. + * + * Permission/listing helpers stay functional so the Settings UI still + * renders without errors; the two `sync*` entrypoints return an empty + * SyncResult. */ import * as Calendar from 'expo-calendar'; import { Platform } from 'react-native'; -import type { CompletedTrip, SavedTrainRef } from '../types/train'; -import { formatDateForDisplay } from '../utils/date-helpers'; -import { gtfsParser } from '../utils/gtfs-parser'; import { logger } from '../utils/logger'; -import { haversineDistance } from '../utils/distance'; -import { formatTime, parseTimeToMinutes } from '../utils/time-formatting'; -import { getMinutesInTimezone } from '../utils/timezone'; -import { stationLoader } from './station-loader'; -import { TrainStorageService } from './storage'; - export interface DeviceCalendar { id: string; @@ -34,48 +35,20 @@ export interface SyncResult { added: number; skipped: number; addedTrips: AddedTripInfo[]; - /** Total events returned by the calendar API (before pattern filtering) */ totalCalendarEvents?: number; - /** Short reason if parsed is 0 */ failReason?: 'gtfs_not_loaded' | 'no_calendar_events' | 'no_pattern_match'; } -interface MatchedTrip { - tripId: string; - fromStopId: string; - fromStopName: string; - toStopId: string; - toStopName: string; - departTime: string; - arriveTime: string; - trainNumber: string; - routeName: string; - eventDate: Date; -} - -const TRAIN_EVENT_PATTERN = /train\s+to\s+(.+)/i; -const TIME_TOLERANCE_MINUTES = 15; - -/** - * Request calendar read permission from the user. - * Returns true if granted. - */ export async function requestCalendarPermission(): Promise { const { status } = await Calendar.requestCalendarPermissionsAsync(); return status === 'granted'; } -/** - * Check if calendar permission is already granted. - */ export async function hasCalendarPermission(): Promise { const { status } = await Calendar.getCalendarPermissionsAsync(); return status === 'granted'; } -/** - * Get list of device calendars for the user to pick from. - */ export async function getDeviceCalendars(): Promise { const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); return calendars.map(cal => ({ @@ -83,510 +56,35 @@ export async function getDeviceCalendars(): Promise { title: cal.title, color: cal.color ?? '#999999', source: - Platform.OS === 'ios' ? (cal.source?.name ?? 'Unknown') : (cal.source?.name ?? cal.accessLevel ?? 'Unknown'), + Platform.OS === 'ios' + ? (cal.source?.name ?? 'Unknown') + : (cal.source?.name ?? cal.accessLevel ?? 'Unknown'), })); } -/** - * Parse GTFS 24h time string (e.g. "14:30:00") to minutes since midnight. - */ -function gtfsTimeToMinutes(gtfsTime: string): number { - const parts = gtfsTime.split(':'); - const h = parseInt(parts[0], 10); - const m = parseInt(parts[1], 10); - return h * 60 + m; -} - -/** - * Search for a station, trying full name first then stripping trailing state abbreviation. - */ -function resolveStation(name: string) { - let stations = gtfsParser.searchStations(name); - if (stations.length === 0) { - const withoutState = name.replace(/\s+[A-Za-z]{2}$/, '').trim(); - if (withoutState !== name && withoutState.length > 0) { - stations = gtfsParser.searchStations(withoutState); - } - } - return stations.length > 0 ? stations[0] : null; -} - -/** - * Format a Date into "h:mm AM/PM" display string. - */ -function formatDateToAmPm(date: Date): string { - let h = date.getHours(); - const m = String(date.getMinutes()).padStart(2, '0'); - const ampm = h >= 12 ? 'PM' : 'AM'; - h = h % 12 || 12; - return `${h}:${m} ${ampm}`; -} - -/** - * Extract a CompletedTrip directly from a calendar event without GTFS validation. - * Used when matchGtfs is false — blindly trusts calendar data. - */ -function extractTripFromEvent(event: Calendar.Event): CompletedTrip | null { - const match = event.title.match(TRAIN_EVENT_PATTERN); - if (!match) return null; - - const destination = match[1].trim(); - const startDate = new Date(event.startDate); - const endDate = event.endDate ? new Date(event.endDate) : null; - const eventDate = new Date(startDate); - eventDate.setHours(0, 0, 0, 0); - - const originLocation = event.location?.trim() || ''; - - // Best-effort station name/code resolution (won't fail if GTFS not loaded) - const destStation = gtfsParser.isLoaded ? resolveStation(destination) : null; - const originStation = originLocation && gtfsParser.isLoaded ? resolveStation(originLocation) : null; - - let duration: number | undefined; - if (endDate) { - duration = Math.round((endDate.getTime() - startDate.getTime()) / 60000); - if (duration <= 0) duration = undefined; - } - - let distance: number | undefined; - if (originStation && destStation) { - try { - const fromStn = stationLoader.getStationByCode(originStation.stop_id); - const toStn = stationLoader.getStationByCode(destStation.stop_id); - if (fromStn && toStn) { - distance = haversineDistance(fromStn.lat, fromStn.lon, toStn.lat, toStn.lon); - } - } catch { /* best effort */ } - } - - // Synthetic deterministic trip ID based on event content - const tripId = `cal-${eventDate.getTime()}-${destination.toLowerCase().replace(/[^a-z0-9]/g, '')}`; - - return { - tripId, - trainNumber: '', - routeName: '', - from: originStation?.stop_name ?? originLocation, - to: destStation?.stop_name ?? destination, - fromCode: originStation?.stop_id ?? '', - toCode: destStation?.stop_id ?? '', - departTime: formatDateToAmPm(startDate), - arriveTime: endDate ? formatDateToAmPm(endDate) : '', - date: formatDateForDisplay(eventDate), - travelDate: eventDate.getTime(), - completedAt: Date.now(), - duration, - distance, - syncedFromCalendar: true, - }; -} - -/** - * Normalize a past date to the same day-of-week in the current week. - * This allows GTFS lookups to succeed for older rides, since the current - * GTFS cache only covers current/near-future dates but train schedules - * are consistent week-to-week. - */ -function normalizeToCurrentWeek(date: Date): Date { - const now = new Date(); - const today = new Date(now); - today.setHours(0, 0, 0, 0); - const currentDayOfWeek = today.getDay(); - const targetDayOfWeek = date.getDay(); - const diff = targetDayOfWeek - currentDayOfWeek; - const normalized = new Date(today); - normalized.setDate(today.getDate() + diff); - return normalized; -} - -/** - * Match a single calendar event against GTFS data. - * Uses event location as origin station and title destination. - * Returns the matched trip info or null if no match found. - */ -function matchEventToTrip(eventTitle: string, eventStartDate: Date, eventLocation?: string): MatchedTrip | null { - const match = eventTitle.match(TRAIN_EVENT_PATTERN); - if (!match) return null; - - const destination = match[1].trim(); - const eventDate = new Date(eventStartDate); - eventDate.setHours(0, 0, 0, 0); - const gtfsLookupDate = normalizeToCurrentWeek(eventDate); - - const destStation = resolveStation(destination); - if (!destStation) { - logger.info(`Calendar sync: no station found for destination "${destination}"`); - return null; - } - logger.info(`Calendar sync: destination "${destination}" → "${destStation.stop_name}" (${destStation.stop_id})`); - - // If event has a location, use it as the origin station - const originLocation = eventLocation?.trim(); - if (originLocation) { - const originStation = resolveStation(originLocation); - if (originStation) { - logger.info( - `Calendar sync: origin "${originLocation}" → "${originStation.stop_name}" (${originStation.stop_id})` - ); - - // GTFS times are in the agency timezone — convert event time to that timezone - const eventMinutesAtOrigin = getMinutesInTimezone(eventStartDate, gtfsParser.agencyTimezone); - - // Use findTripsWithStops for precise origin→destination matching - const trips = gtfsParser.findTripsWithStops(originStation.stop_id, destStation.stop_id, gtfsLookupDate); - logger.info( - `Calendar sync: ${trips.length} trips from ${originStation.stop_id} to ${destStation.stop_id} on ${eventDate.toLocaleDateString()}` - ); - - for (const trip of trips) { - const departMinutes = gtfsTimeToMinutes(trip.fromStop.departure_time); - if (Math.abs(departMinutes - eventMinutesAtOrigin) <= TIME_TOLERANCE_MINUTES) { - const trainNumber = gtfsParser.getTrainNumber(trip.tripId) || ''; - const routeId = gtfsParser.getRouteIdForTrip(trip.tripId); - const routeName = routeId ? gtfsParser.getRouteName(routeId) : 'Unknown Route'; - - return { - tripId: trip.tripId, - fromStopId: trip.fromStop.stop_id, - fromStopName: trip.fromStop.stop_name, - toStopId: trip.toStop.stop_id, - toStopName: trip.toStop.stop_name, - departTime: formatTime(trip.fromStop.departure_time), - arriveTime: formatTime(trip.toStop.arrival_time), - trainNumber, - routeName, - eventDate, - }; - } - } - } else { - logger.info(`Calendar sync: no station found for origin "${originLocation}", falling back to time matching`); - } - } - - // Fallback: no location or origin not found — infer origin by matching departure time at any stop - const tripIds = gtfsParser.getTripsForStop(destStation.stop_id, gtfsLookupDate); - logger.info( - `Calendar sync: fallback — ${tripIds.length} trips at ${destStation.stop_id}` - ); - - for (const tripId of tripIds) { - const stopTimes = gtfsParser.getStopTimesForTrip(tripId); - if (stopTimes.length < 2) continue; - - for (const stop of stopTimes) { - // GTFS times are in the agency timezone — convert event time to that timezone - const eventMinutesAtStop = getMinutesInTimezone(eventStartDate, gtfsParser.agencyTimezone); - const stopMinutes = gtfsTimeToMinutes(stop.departure_time); - if (Math.abs(stopMinutes - eventMinutesAtStop) <= TIME_TOLERANCE_MINUTES) { - const destStopTime = stopTimes.find(s => s.stop_id === destStation.stop_id); - if (!destStopTime) continue; - if (stop.stop_sequence >= destStopTime.stop_sequence) continue; - - const trainNumber = gtfsParser.getTrainNumber(tripId) || ''; - const routeId = gtfsParser.getRouteIdForTrip(tripId); - const routeName = routeId ? gtfsParser.getRouteName(routeId) : 'Unknown Route'; - - return { - tripId, - fromStopId: stop.stop_id, - fromStopName: stop.stop_name, - toStopId: destStopTime.stop_id, - toStopName: destStopTime.stop_name, - departTime: formatTime(stop.departure_time), - arriveTime: formatTime(destStopTime.arrival_time), - trainNumber, - routeName, - eventDate, - }; - } - } - } - - return null; -} - -/** - * Fetch events from the Calendar API, chunking into 6-month intervals - * to avoid iOS EventKit silently truncating results on large date ranges. - */ -async function fetchCalendarEventsChunked( - calendarIds: string[], - startDate: Date, - endDate: Date -): Promise { - const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000; - const rangeMs = endDate.getTime() - startDate.getTime(); - - // Small range — single fetch is fine - if (rangeMs <= SIX_MONTHS_MS) { - return Calendar.getEventsAsync(calendarIds, startDate, endDate); - } - - // Large range — chunk into 6-month windows - const allEvents: Calendar.Event[] = []; - const seenIds = new Set(); - let chunkStart = new Date(startDate); - - while (chunkStart.getTime() < endDate.getTime()) { - const chunkEnd = new Date(Math.min(chunkStart.getTime() + SIX_MONTHS_MS, endDate.getTime())); - const chunk = await Calendar.getEventsAsync(calendarIds, chunkStart, chunkEnd); - for (const event of chunk) { - const eid = event.id ?? `${event.title}-${event.startDate}`; - if (!seenIds.has(eid)) { - seenIds.add(eid); - allEvents.push(event); - } - } - logger.info(`Calendar sync: chunk ${chunkStart.toLocaleDateString()}–${chunkEnd.toLocaleDateString()}: ${chunk.length} events`); - chunkStart = chunkEnd; - } - - return allEvents; -} - -/** - * Fetch train events from calendars within a date range. - */ -async function fetchTrainEvents( - calendarIds: string[], - startDate: Date, - endDate: Date -): Promise<{ matched: Calendar.Event[]; totalEvents: number }> { - logger.info( - `Calendar sync: fetching from ${calendarIds.length} calendar(s), ${startDate.toLocaleDateString()} to ${endDate.toLocaleDateString()}` - ); - const events = await fetchCalendarEventsChunked(calendarIds, startDate, endDate); - logger.info(`Calendar sync: ${events.length} total events found`); - - // Log first few event titles for debugging when no matches - if (events.length > 0 && events.length <= 20) { - for (const e of events) { - logger.info(`Calendar sync: event: "${e.title}"`); - } - } else if (events.length > 20) { - for (let i = 0; i < 10; i++) { - logger.info(`Calendar sync: event: "${events[i].title}"`); - } - logger.info(`Calendar sync: ... and ${events.length - 10} more`); - } - - const matched: Calendar.Event[] = []; - for (const e of events) { - if (TRAIN_EVENT_PATTERN.test(e.title)) { - logger.info(`Calendar sync: matched "${e.title}"`); - matched.push(e); - } - } - logger.info(`Calendar sync: ${matched.length}/${events.length} matched train pattern`); - return { matched, totalEvents: events.length }; -} - -/** - * Sync past trips — scans selected calendars for past train events - * and adds matched trips to history. - */ -export async function syncPastTrips(calendarIds: string[], scanDays: number, matchGtfs: boolean = false): Promise { - const result: SyncResult = { parsed: 0, matched: 0, added: 0, skipped: 0, addedTrips: [] }; - - // When matchGtfs is enabled, require GTFS data for precise trip matching - if (matchGtfs && !gtfsParser.isLoaded) { - logger.error('Calendar sync: GTFS data not loaded — cannot sync'); - result.failReason = 'gtfs_not_loaded'; - return result; - } - - const now = new Date(); - const endDate = new Date(now); - if (scanDays === -1) { - // "All" — include through today - endDate.setHours(23, 59, 59, 999); - } else { - endDate.setDate(endDate.getDate() - 1); - endDate.setHours(23, 59, 59, 999); - } - - const startDate = new Date(now); - if (scanDays === -1) { - // "All" option - scan as far back as possible - startDate.setFullYear(startDate.getFullYear() - 10); - } else { - startDate.setDate(startDate.getDate() - scanDays); - } - startDate.setHours(0, 0, 0, 0); - - const { matched: trainEvents, totalEvents } = await fetchTrainEvents(calendarIds, startDate, endDate); - result.parsed = trainEvents.length; - result.totalCalendarEvents = totalEvents; - if (trainEvents.length === 0) { - result.failReason = totalEvents === 0 ? 'no_calendar_events' : 'no_pattern_match'; - return result; - } - - const existingHistory = await TrainStorageService.getTripHistory(); - const existingKeys = new Set(existingHistory.map(h => `${h.tripId}|${h.fromCode}|${h.toCode}|${h.travelDate}`)); - - for (const event of trainEvents) { - let entry: CompletedTrip | null; - - if (!matchGtfs) { - // Trust calendar data directly — no GTFS cross-reference - entry = extractTripFromEvent(event); - } else { - // Full GTFS matching path - const matched = matchEventToTrip(event.title, new Date(event.startDate), event.location ?? undefined); - if (!matched) { continue; } - - // Calculate duration from times - let duration: number | undefined; - try { - const departMinutes = parseTimeToMinutes(matched.departTime); - const arriveMinutes = parseTimeToMinutes(matched.arriveTime); - duration = arriveMinutes - departMinutes; - if (duration < 0) { - duration += 24 * 60; - } - } catch (error) { - logger.error('Calendar sync: Error calculating duration:', error); - } - - // Calculate distance as the crow flies using station coordinates - let distance: number | undefined; - try { - const fromStation = stationLoader.getStationByCode(matched.fromStopId); - const toStation = stationLoader.getStationByCode(matched.toStopId); - if (fromStation && toStation) { - distance = haversineDistance(fromStation.lat, fromStation.lon, toStation.lat, toStation.lon); - } - } catch (error) { - logger.error('Calendar sync: Error calculating distance:', error); - } - - // Use a stable trip ID derived from matched train info rather than the - // volatile GTFS trip ID, which changes when timetable data is refreshed. - // This prevents duplicate history entries when re-syncing after a GTFS update. - const stableTripId = `cal-${matched.eventDate.getTime()}-${matched.trainNumber || matched.fromStopId}-${matched.toStopId}`; - - entry = { - tripId: stableTripId, - trainNumber: matched.trainNumber, - routeName: matched.routeName, - from: matched.fromStopName, - to: matched.toStopName, - fromCode: matched.fromStopId, - toCode: matched.toStopId, - departTime: matched.departTime, - arriveTime: matched.arriveTime, - date: formatDateForDisplay(matched.eventDate), - travelDate: matched.eventDate.getTime(), - completedAt: Date.now(), - duration, - distance, - syncedFromCalendar: true, - }; - } - - if (!entry) continue; - - const key = `${entry.tripId}|${entry.fromCode}|${entry.toCode}|${entry.travelDate}`; - result.matched++; - - if (existingKeys.has(key)) { - result.skipped++; - } else { - const added = await TrainStorageService.addToHistory(entry); - if (added) { - result.added++; - existingKeys.add(key); - result.addedTrips.push({ - from: entry.from, - to: entry.to, - date: entry.date, - }); - } else { - result.skipped++; - } - } - } - - return result; -} - -/** - * Sync future trips — scans calendars for upcoming train events - * and adds matched trips to saved trains (My Trains). - * Called automatically on app load. - */ -export async function syncFutureTrips(calendarIds: string[], matchGtfs: boolean = false): Promise { - const result: SyncResult = { parsed: 0, matched: 0, added: 0, skipped: 0, addedTrips: [] }; - - // Future trips are stored as SavedTrainRef which requires a valid GTFS trip ID - // for reconstruction. Skip when GTFS matching is disabled. - if (!matchGtfs) { - logger.info('Calendar sync (future): skipped — GTFS matching disabled'); - return result; - } - - if (!gtfsParser.isLoaded) { - logger.error('Calendar sync (future): GTFS data not loaded — cannot sync'); - result.failReason = 'gtfs_not_loaded'; - return result; - } - - const now = new Date(); - const startDate = new Date(now); - startDate.setHours(0, 0, 0, 0); - - const endDate = new Date(now); - endDate.setDate(endDate.getDate() + 90); - endDate.setHours(23, 59, 59, 999); - - const { matched: trainEvents, totalEvents } = await fetchTrainEvents(calendarIds, startDate, endDate); - result.parsed = trainEvents.length; - result.totalCalendarEvents = totalEvents; - if (trainEvents.length === 0) { - result.failReason = totalEvents === 0 ? 'no_calendar_events' : 'no_pattern_match'; - return result; - } - - // Load existing saved trains for dedup - const existingRefs = await TrainStorageService.getSavedTrainRefs(); - const existingKeys = new Set( - existingRefs.map(r => `${r.tripId}|${r.fromCode ?? ''}|${r.toCode ?? ''}|${r.travelDate ?? 0}`) - ); - - for (const event of trainEvents) { - const matched = matchEventToTrip(event.title, new Date(event.startDate), event.location ?? undefined); - if (!matched) continue; - - result.matched++; - - const ref: SavedTrainRef = { - tripId: matched.tripId, - fromCode: matched.fromStopId, - toCode: matched.toStopId, - travelDate: matched.eventDate.getTime(), - savedAt: Date.now(), - }; - - const key = `${ref.tripId}|${ref.fromCode ?? ''}|${ref.toCode ?? ''}|${ref.travelDate ?? 0}`; - if (existingKeys.has(key)) { - result.skipped++; - } else { - const saved = await TrainStorageService.saveTrainRef(ref); - if (saved) { - result.added++; - existingKeys.add(key); - result.addedTrips.push({ - from: matched.fromStopName, - to: matched.toStopName, - date: formatDateForDisplay(matched.eventDate), - }); - } else { - result.skipped++; - } - } - } - - return result; +const DISABLED_RESULT: SyncResult = { + parsed: 0, + matched: 0, + added: 0, + skipped: 0, + addedTrips: [], + totalCalendarEvents: 0, + failReason: 'gtfs_not_loaded', +}; + +export async function syncPastTrips( + _calendarIds: string[], + _scanDays: number, + _matchGtfs: boolean = false, +): Promise { + logger.info('Calendar sync (past): disabled during GTFS → API migration'); + return DISABLED_RESULT; +} + +export async function syncFutureTrips( + _calendarIds: string[], + _matchGtfs: boolean = false, +): Promise { + logger.info('Calendar sync (future): disabled during GTFS → API migration'); + return DISABLED_RESULT; } diff --git a/apps/mobile/services/gtfs-sync.ts b/apps/mobile/services/gtfs-sync.ts deleted file mode 100644 index 7705c4a..0000000 --- a/apps/mobile/services/gtfs-sync.ts +++ /dev/null @@ -1,465 +0,0 @@ -/** - * GTFS weekly sync service - * - Checks freshness (3 days) - * - Fetches GTFS.zip from Amtrak - * - Unzips in memory (fflate) and parses CSVs - * - Caches parsed JSON as compressed files on the filesystem - * - Applies cached data to the GTFS parser - */ - -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { File, Directory, Paths } from 'expo-file-system'; -import { strFromU8, strToU8, unzipSync, zlibSync, unzlibSync } from 'fflate'; -import type { CalendarDateException, CalendarEntry, Route, Shape, Stop, StopTime, Trip } from '../types/train'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { shapeLoader } from './shape-loader'; -import { logger } from '../utils/logger'; -import { fetchWithTimeout } from '../utils/fetch-with-timeout'; - -const GTFS_URL = 'https://content.amtrak.com/content/gtfs/GTFS.zip'; - -const cacheDir = new Directory(Paths.document, 'gtfs-cache'); - -const CACHE_FILES = { - routes: new File(cacheDir, 'routes.json.z'), - stops: new File(cacheDir, 'stops.json.z'), - stopTimes: new File(cacheDir, 'stop_times.json.z'), - shapes: new File(cacheDir, 'shapes.json.z'), - trips: new File(cacheDir, 'trips.json.z'), - calendar: new File(cacheDir, 'calendar.json.z'), - calendarDates: new File(cacheDir, 'calendar_dates.json.z'), - agencyTimezone: new File(cacheDir, 'agency_timezone.txt'), -}; - -const STORAGE_KEYS = { - LAST_FETCH: 'GTFS_LAST_FETCH', -}; - -// Old AsyncStorage keys to clean up during migration -const LEGACY_STORAGE_KEYS = [ - 'GTFS_ROUTES_JSON', 'GTFS_STOPS_JSON', 'GTFS_STOP_TIMES_JSON', - 'GTFS_SHAPES_JSON', 'GTFS_TRIPS_JSON', 'GTFS_CALENDAR_JSON', - 'GTFS_CALENDAR_DATES_JSON', 'GTFS_AGENCY_TIMEZONE', -]; - -function isOlderThanDays(dateMs: number, days: number): boolean { - const now = Date.now(); - const ms = days * 24 * 60 * 60 * 1000; - return now - dateMs > ms; -} - -function ensureCacheDirSync() { - if (!cacheDir.exists) { - cacheDir.create({ intermediates: true }); - } -} - -async function ensureCacheDir() { - ensureCacheDirSync(); - // One-time migration: remove old AsyncStorage GTFS keys to free space - try { - await AsyncStorage.multiRemove(LEGACY_STORAGE_KEYS); - } catch { - // Ignore — keys may already be gone - } -} - -/** Write JSON data as zlib-compressed file */ -function writeCompressedJSON(file: File, data: unknown): void { - const json = JSON.stringify(data); - const compressed = zlibSync(strToU8(json)); - if (file.exists) { file.delete(); } - file.create(); - file.write(compressed); -} - -/** Read zlib-compressed file and parse as JSON (sync I/O — faster than async bridge roundtrip) */ -function readCompressedJSON(file: File): T | null { - try { - if (!file.exists) return null; - const compressed = file.bytesSync(); - const json = strFromU8(unzlibSync(compressed)); - return JSON.parse(json) as T; - } catch { - return null; - } -} - -/** Convert full Shape objects to compact [lat, lon] tuples for smaller cache */ -function compactifyShapes(shapes: Record): Record { - const compact: Record = {}; - for (const [id, points] of Object.entries(shapes)) { - compact[id] = points.map(p => [p.shape_pt_lat, p.shape_pt_lon]); - } - return compact; -} - -/** Expand compact [lat, lon] tuples back to full Shape objects */ -function expandShapes(compact: Record): Record { - const shapes: Record = {}; - for (const [id, points] of Object.entries(compact)) { - shapes[id] = points.map(([lat, lon], i) => ({ - shape_id: id, - shape_pt_lat: lat, - shape_pt_lon: lon, - shape_pt_sequence: i, - })); - } - return shapes; -} - -// Basic CSV parser that respects quoted fields -function parseCSV(text: string): Array> { - const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0); - if (lines.length === 0) return []; - const header = splitCSVLine(lines[0]); - const rows: Array> = []; - for (let i = 1; i < lines.length; i++) { - const cols = splitCSVLine(lines[i]); - const row: Record = {}; - for (let j = 0; j < header.length; j++) { - row[header[j]] = cols[j] ?? ''; - } - rows.push(row); - } - return rows; -} - -function splitCSVLine(line: string): string[] { - const result: string[] = []; - let cur = ''; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (ch === '"') { - if (inQuotes && line[i + 1] === '"') { - // escaped quote - cur += '"'; - i++; - } else { - inQuotes = !inQuotes; - } - } else if (ch === ',' && !inQuotes) { - result.push(cur); - cur = ''; - } else { - cur += ch; - } - } - result.push(cur); - // Trim outer quotes - return result.map(v => (v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v)); -} - -async function fetchZipBytes(): Promise { - const res = await fetchWithTimeout(GTFS_URL, { timeoutMs: 30000 }); - if (!res.ok) throw new Error(`GTFS fetch failed: ${res.status}`); - const buf = await res.arrayBuffer(); - return new Uint8Array(buf); -} - -function buildRoutes(rows: Array>): Route[] { - return rows - .map(r => ({ - route_id: r['route_id'], - agency_id: r['agency_id'] || undefined, - route_short_name: r['route_short_name'] || undefined, - route_long_name: r['route_long_name'] || r['route_short_name'] || r['route_id'], - route_type: r['route_type'] || undefined, - route_url: r['route_url'] || undefined, - route_color: r['route_color'] || undefined, - route_text_color: r['route_text_color'] || undefined, - })) - .filter(r => !!r.route_id); -} - -function buildStops(rows: Array>): Stop[] { - return rows - .map(r => ({ - stop_id: r['stop_id'], - stop_name: r['stop_name'], - stop_url: r['stop_url'] || undefined, - stop_timezone: r['stop_timezone'] || undefined, - stop_lat: parseFloat(r['stop_lat']), - stop_lon: parseFloat(r['stop_lon']), - })) - .filter(s => !!s.stop_id && !!s.stop_name); -} - -function buildStopTimes(rows: Array>): Record { - const grouped: Record = {}; - for (const r of rows) { - const trip_id = r['trip_id']; - if (!trip_id) continue; - const st: StopTime = { - trip_id, - arrival_time: r['arrival_time'], - departure_time: r['departure_time'], - stop_id: r['stop_id'], - stop_sequence: parseInt(r['stop_sequence'] || '0', 10), - pickup_type: r['pickup_type'] ? parseInt(r['pickup_type'], 10) : undefined, - drop_off_type: r['drop_off_type'] ? parseInt(r['drop_off_type'], 10) : undefined, - timepoint: r['timepoint'] ? parseInt(r['timepoint'], 10) : undefined, - }; - if (!grouped[trip_id]) grouped[trip_id] = []; - grouped[trip_id].push(st); - } - // sort sequences per trip - Object.values(grouped).forEach(arr => arr.sort((a, b) => a.stop_sequence - b.stop_sequence)); - return grouped; -} - -function buildShapes(rows: Array>): Record { - const grouped: Record = {}; - for (const r of rows) { - const shape_id = r['shape_id']; - if (!shape_id) continue; - const shape: Shape = { - shape_id, - shape_pt_lat: parseFloat(r['shape_pt_lat']), - shape_pt_lon: parseFloat(r['shape_pt_lon']), - shape_pt_sequence: parseInt(r['shape_pt_sequence'] || '0', 10), - }; - if (!grouped[shape_id]) grouped[shape_id] = []; - grouped[shape_id].push(shape); - } - // sort by sequence - Object.values(grouped).forEach(arr => arr.sort((a, b) => a.shape_pt_sequence - b.shape_pt_sequence)); - return grouped; -} - -function buildTrips(rows: Array>): Trip[] { - return rows - .map(r => ({ - route_id: r['route_id'], - trip_id: r['trip_id'], - trip_short_name: r['trip_short_name'] || undefined, - trip_headsign: r['trip_headsign'] || undefined, - service_id: r['service_id'] || '', - })) - .filter(t => !!t.trip_id); -} - -function buildCalendar(rows: Array>): CalendarEntry[] { - return rows - .map(r => ({ - service_id: r['service_id'], - monday: r['monday'] === '1', - tuesday: r['tuesday'] === '1', - wednesday: r['wednesday'] === '1', - thursday: r['thursday'] === '1', - friday: r['friday'] === '1', - saturday: r['saturday'] === '1', - sunday: r['sunday'] === '1', - start_date: parseInt(r['start_date'] || '0', 10), - end_date: parseInt(r['end_date'] || '0', 10), - })) - .filter(c => !!c.service_id); -} - -function parseAgencyTimezone(rows: Array>): string | null { - for (const r of rows) { - const tz = r['agency_timezone']; - if (tz) return tz; - } - return null; -} - -function buildCalendarDates(rows: Array>): CalendarDateException[] { - return rows - .map(r => ({ - service_id: r['service_id'], - date: parseInt(r['date'] || '0', 10), - exception_type: parseInt(r['exception_type'] || '0', 10), - })) - .filter(c => !!c.service_id && c.date > 0); -} - -type ProgressUpdate = { step: string; progress: number; detail?: string }; - -export async function ensureFreshGTFS(onProgress?: (update: ProgressUpdate) => void): Promise<{ usedCache: boolean }> { - try { - const report = async (step: string, progress: number, detail?: string) => { - onProgress?.({ step, progress: Math.min(1, Math.max(0, progress)), detail }); - // Progressive console logging - if (detail) { - logger.info(`[GTFS Refresh] ${step} (${Math.round(progress * 100)}%): ${detail}`); - } else { - logger.info(`[GTFS Refresh] ${step} (${Math.round(progress * 100)}%)`); - } - // Yield to the event loop so React can flush state updates and re-render - await new Promise(resolve => setTimeout(resolve, 0)); - }; - - await report('Checking GTFS cache', 0.05); - - await ensureCacheDir(); - - const lastFetchStr = await AsyncStorage.getItem(STORAGE_KEYS.LAST_FETCH); - const lastFetchMs = lastFetchStr ? parseInt(lastFetchStr, 10) : 0; - - // If cache is fresh, apply and return - if (lastFetchMs && !isOlderThanDays(lastFetchMs, 3)) { - const routes = readCompressedJSON(CACHE_FILES.routes); - const stops = readCompressedJSON(CACHE_FILES.stops); - const stopTimes = readCompressedJSON>(CACHE_FILES.stopTimes); - const compactShapes = readCompressedJSON>(CACHE_FILES.shapes); - const trips = readCompressedJSON(CACHE_FILES.trips); - const calendar = readCompressedJSON(CACHE_FILES.calendar); - const calendarDates = readCompressedJSON(CACHE_FILES.calendarDates); - const agencyTimezone = CACHE_FILES.agencyTimezone.exists ? CACHE_FILES.agencyTimezone.textSync() || null : null; - if (routes && stops && stopTimes) { - const shapes = compactShapes ? expandShapes(compactShapes) : {}; - gtfsParser.overrideData(routes, stops, stopTimes, shapes, trips || [], calendar || [], calendarDates || [], agencyTimezone); - shapeLoader.initialize(shapes); - await report('Using cached GTFS', 1, 'Cache age < 3 days'); - return { usedCache: true }; - } - } - - await report('GTFS.zip', 0.1, 'Fetching latest schedule'); - // Fetch and rebuild cache - const zipBytes = await fetchZipBytes(); - await report('Download complete', 0.2); - const files = unzipSync(zipBytes); - await report('Unzipping archive', 0.3); - - const routesTxt = files['routes.txt'] ? strFromU8(files['routes.txt']) : ''; - const stopsTxt = files['stops.txt'] ? strFromU8(files['stops.txt']) : ''; - const stopTimesTxt = files['stop_times.txt'] ? strFromU8(files['stop_times.txt']) : ''; - const shapesTxt = files['shapes.txt'] ? strFromU8(files['shapes.txt']) : ''; - const tripsTxt = files['trips.txt'] ? strFromU8(files['trips.txt']) : ''; - const calendarTxt = files['calendar.txt'] ? strFromU8(files['calendar.txt']) : ''; - const calendarDatesTxt = files['calendar_dates.txt'] ? strFromU8(files['calendar_dates.txt']) : ''; - const agencyTxt = files['agency.txt'] ? strFromU8(files['agency.txt']) : ''; - - if (!routesTxt || !stopsTxt || !stopTimesTxt) { - logger.error('[GTFS Refresh] Missing expected GTFS files (routes/stops/stop_times)'); - throw new Error('Missing expected GTFS files (routes/stops/stop_times)'); - } - - await report('Parsing routes', 0.35); - const routes = buildRoutes(parseCSV(routesTxt)); - await report('Parsing stops', 0.45); - const stops = buildStops(parseCSV(stopsTxt)); - await report('Parsing trips', 0.55); - const trips = tripsTxt ? buildTrips(parseCSV(tripsTxt)) : []; - await report('Parsing stop times', 0.7); - const stopTimes = buildStopTimes(parseCSV(stopTimesTxt)); - await report('Parsing shapes', 0.75); - const shapes = shapesTxt ? buildShapes(parseCSV(shapesTxt)) : {}; - await report('Parsing calendar', 0.8); - const calendar = calendarTxt ? buildCalendar(parseCSV(calendarTxt)) : []; - const calendarDates = calendarDatesTxt ? buildCalendarDates(parseCSV(calendarDatesTxt)) : []; - const agencyTimezone = agencyTxt ? parseAgencyTimezone(parseCSV(agencyTxt)) : null; - - await report('Persisting cache', 0.9, 'Writing compressed data to device storage'); - - // Write compressed JSON files to filesystem (no size limits) - writeCompressedJSON(CACHE_FILES.routes, routes); - writeCompressedJSON(CACHE_FILES.stops, stops); - writeCompressedJSON(CACHE_FILES.stopTimes, stopTimes); - writeCompressedJSON(CACHE_FILES.shapes, compactifyShapes(shapes)); - writeCompressedJSON(CACHE_FILES.trips, trips); - writeCompressedJSON(CACHE_FILES.calendar, calendar); - writeCompressedJSON(CACHE_FILES.calendarDates, calendarDates); - const tzFile = CACHE_FILES.agencyTimezone; - if (tzFile.exists) { tzFile.delete(); } - tzFile.create(); - tzFile.write(agencyTimezone || ''); - // Store only the timestamp in AsyncStorage (tiny metadata) - await AsyncStorage.setItem(STORAGE_KEYS.LAST_FETCH, String(Date.now())); - - gtfsParser.overrideData(routes, stops, stopTimes, shapes, trips, calendar, calendarDates, agencyTimezone); - - // Initialize shape loader for map rendering - shapeLoader.initialize(shapes); - - await report('Refresh complete', 1, 'Applied latest GTFS'); - return { usedCache: false }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.error(`[GTFS Refresh] GTFS sync failed: ${msg}`, err); - onProgress?.({ step: 'GTFS refresh failed', progress: 1, detail: msg || 'Check network connection' }); - return { usedCache: true }; - } -} - -export async function hasCachedGTFS(): Promise { - return CACHE_FILES.routes.exists && CACHE_FILES.stops.exists && CACHE_FILES.stopTimes.exists; -} - -export async function isCacheStale(): Promise { - const lastFetchStr = await AsyncStorage.getItem(STORAGE_KEYS.LAST_FETCH); - const lastFetchMs = lastFetchStr ? parseInt(lastFetchStr, 10) : 0; - return !lastFetchMs || isOlderThanDays(lastFetchMs, 3); -} - -// Yield to the event loop so the JS thread can handle pending UI work -const yieldToUI = () => new Promise(resolve => setTimeout(resolve, 0)); - -/** - * Load cached GTFS data into the parser (called on app startup) - * This doesn't check staleness - just loads whatever is cached. - * Reads compressed files from the filesystem and yields between - * large parses to avoid blocking the UI thread. - */ -export async function loadCachedGTFS(): Promise { - try { - ensureCacheDirSync(); - - // Read routes + stops (small files, needed to check if cache exists) - const routes = readCompressedJSON(CACHE_FILES.routes); - const stops = readCompressedJSON(CACHE_FILES.stops); - if (!routes || !stops) { - logger.info('[GTFS] No cached data found'); - return false; - } - - await yieldToUI(); - - // Read remaining files with yields between heavy parses to avoid blocking UI - const stopTimes = readCompressedJSON>(CACHE_FILES.stopTimes); - if (!stopTimes) { - logger.info('[GTFS] No cached data found'); - return false; - } - - await yieldToUI(); - const trips = readCompressedJSON(CACHE_FILES.trips); - - await yieldToUI(); - const calendar = readCompressedJSON(CACHE_FILES.calendar); - const calendarDates = readCompressedJSON(CACHE_FILES.calendarDates); - - const agencyTimezone = CACHE_FILES.agencyTimezone.exists ? CACHE_FILES.agencyTimezone.textSync() || null : null; - - // Load without shapes — they are deferred to after splash hides - gtfsParser.overrideData(routes, stops, stopTimes, {}, trips || [], calendar || [], calendarDates || [], agencyTimezone); - logger.info('[GTFS] Loaded core cached data on startup (shapes deferred)'); - return true; - } catch (error) { - logger.error('[GTFS] Failed to load cached data:', error); - return false; - } -} - -/** Load shapes in the background after splash screen is hidden */ -export async function loadDeferredShapes(): Promise { - try { - // Yield before heavy sync I/O so React can flush pending state updates (e.g. hide loading screen) - await yieldToUI(); - - const compactShapes = readCompressedJSON>(CACHE_FILES.shapes); - if (!compactShapes) return; - - await yieldToUI(); - const shapes = expandShapes(compactShapes); - - gtfsParser.updateShapes(shapes); - shapeLoader.initialize(shapes); - logger.info('[GTFS] Deferred shapes loaded'); - } catch (error) { - logger.error('[GTFS] Failed to load deferred shapes:', error); - } -} diff --git a/apps/mobile/services/realtime.ts b/apps/mobile/services/realtime.ts deleted file mode 100644 index 9f5165c..0000000 --- a/apps/mobile/services/realtime.ts +++ /dev/null @@ -1,432 +0,0 @@ -/** - * Real-time train tracking service - * Fetches live positions and delays from Transitdocs GTFS-RT feed - */ - -import { Alert } from 'react-native'; -import GtfsRealtimeBindings from 'gtfs-realtime-bindings'; -import { gtfsParser } from '../utils/gtfs-parser'; -import { extractTrainNumber } from '../utils/train-helpers'; -import { logger } from '../utils/logger'; -import { fetchWithTimeout } from '../utils/fetch-with-timeout'; - -// Track last error alert time to avoid spamming user -let lastErrorAlertTime = 0; -const ERROR_ALERT_COOLDOWN = 60000; // Only show alert once per minute -let consecutiveErrors = 0; -const MAX_SILENT_ERRORS = 3; // Show alert after 3 consecutive errors - -export interface RealtimePosition { - trip_id: string; - latitude: number; - longitude: number; - bearing?: number; - speed?: number; - timestamp: number; - vehicle_id?: string; - train_number?: string; // Extracted train number for matching -} - -export interface RealtimeUpdate { - trip_id: string; - stop_id?: string; - arrival_delay?: number; // seconds - departure_delay?: number; // seconds - schedule_relationship?: 'SCHEDULED' | 'SKIPPED' | 'NO_DATA'; -} - -export interface RealtimeAlert { - trip_id?: string; - route_id?: string; - header: string; - description: string; - severity?: 'INFO' | 'WARNING' | 'SEVERE'; -} - -// Transitdocs GTFS-RT endpoint (consolidates vehicle positions and trip updates) -const TRANSITDOCS_GTFS_RT_URL = 'https://asm-backend.transitdocs.com/gtfs/amtrak'; - -// Cache for real-time data (25 seconds TTL — outlasts 15s poll interval so second consumers get cache hits) -const CACHE_TTL = 25000; -let positionsCache: { data: Map; timestamp: number } | null = null; -let updatesCache: { data: Map; timestamp: number } | null = null; - -// Shared fetch to avoid fetching + decoding the same protobuf twice -let pendingFetch: Promise | null = null; - -async function fetchSharedProtobuf(): Promise { - if (!pendingFetch) { - pendingFetch = fetchProtobuf(TRANSITDOCS_GTFS_RT_URL).finally(() => { - pendingFetch = null; - }); - } - return pendingFetch; -} - -/** - * Show error alert to user (rate-limited) - */ -function showRealtimeErrorAlert(status: number): void { - consecutiveErrors++; - - // Only show alert if enough consecutive errors and cooldown has passed - const now = Date.now(); - if (consecutiveErrors >= MAX_SILENT_ERRORS && now - lastErrorAlertTime > ERROR_ALERT_COOLDOWN) { - lastErrorAlertTime = now; - - let message = 'Unable to fetch live train positions. '; - if (status === 503) { - message += - 'The Transitdocs service is temporarily unavailable. Train positions will update when service is restored.'; - } else if (status === 429) { - message += 'Too many requests. Please wait a moment.'; - } else { - message += `Server returned error ${status}. Please try again later.`; - } - - Alert.alert('Live Data Unavailable', message, [{ text: 'OK', style: 'default' }]); - } -} - -/** - * Reset error counter on successful fetch - */ -function resetErrorCounter(): void { - consecutiveErrors = 0; -} - -/** - * Fetch GTFS-RT protobuf data - */ -async function fetchProtobuf(url: string): Promise { - const response = await fetchWithTimeout(url, { timeoutMs: 15000 }); - if (!response.ok) { - showRealtimeErrorAlert(response.status); - throw new Error(`GTFS-RT fetch failed: ${response.status}`); - } - resetErrorCounter(); - const arrayBuffer = await response.arrayBuffer(); - return new Uint8Array(arrayBuffer); -} - -// extractTrainNumber is now imported from utils/train-helpers - -/** - * Parse GTFS-RT protobuf for vehicle positions. - * Returns both the positions map and a tripId→trainNumber mapping so that - * parseTripUpdates can index delays by the correct train number. - */ -function parseVehiclePositions(buffer: Uint8Array): { positions: Map; trainNumberMap: Map } { - const positions = new Map(); - const trainNumberMap = new Map(); - - try { - const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(buffer); - - for (const entity of feed.entity) { - if (entity.vehicle && entity.vehicle.position && entity.vehicle.trip) { - const tripId = entity.vehicle.trip.tripId || ''; - const vehicleId = entity.vehicle.vehicle?.id ?? ''; - const vehicleIdMatch = vehicleId.match(/_(\d+)$/); - const trainNumber = vehicleIdMatch - ? vehicleIdMatch[1] - : extractTrainNumber(tripId) || tripId; - - trainNumberMap.set(tripId, trainNumber); - - positions.set(tripId, { - trip_id: tripId, - train_number: trainNumber, - latitude: entity.vehicle.position.latitude, - longitude: entity.vehicle.position.longitude, - bearing: entity.vehicle.position.bearing ?? undefined, - speed: entity.vehicle.position.speed ?? undefined, - timestamp: entity.vehicle.timestamp - ? Number(entity.vehicle.timestamp) * 1000 // Convert to milliseconds - : Date.now(), - vehicle_id: entity.vehicle.vehicle?.id ?? undefined, - }); - - // Also index by train number for easier lookup - if (trainNumber !== tripId) { - positions.set(trainNumber, positions.get(tripId)!); - } - } - } - } catch (error) { - logger.error('Error parsing vehicle positions:', error); - } - - return { positions, trainNumberMap }; -} - -/** - * Parse GTFS-RT protobuf for trip updates - * Accepts an optional tripId→trainNumber map (from vehicle positions) so that - * updates for numeric-only GTFS-RT trip IDs can also be indexed by their real - * train number (e.g. "656" instead of "248766"). - */ -function parseTripUpdates(buffer: Uint8Array, trainNumberMap?: Map): Map { - const updates = new Map(); - - try { - const feed = GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(buffer); - - for (const entity of feed.entity) { - if (entity.tripUpdate && entity.tripUpdate.trip) { - const tripId = entity.tripUpdate.trip.tripId || ''; - const trainNumber = trainNumberMap?.get(tripId) || extractTrainNumber(tripId) || tripId; - const stopUpdates: RealtimeUpdate[] = []; - - for (const stopTime of entity.tripUpdate.stopTimeUpdate || []) { - stopUpdates.push({ - trip_id: tripId, - stop_id: stopTime.stopId ?? undefined, - arrival_delay: stopTime.arrival?.delay ?? undefined, - departure_delay: stopTime.departure?.delay ?? undefined, - schedule_relationship: - stopTime.scheduleRelationship === 0 - ? 'SCHEDULED' - : stopTime.scheduleRelationship === 1 - ? 'SKIPPED' - : 'NO_DATA', - }); - } - - if (stopUpdates.length > 0) { - updates.set(tripId, stopUpdates); - // Also index by train number - if (trainNumber !== tripId) { - updates.set(trainNumber, stopUpdates); - } - } - } - } - } catch (error) { - logger.error('Error parsing trip updates:', error); - } - - return updates; -} - -export class RealtimeService { - /** - * Get real-time position for a specific trip or train number - * Supports both trip_id format (e.g., "2026-01-16_AMTK_543") and train number (e.g., "543") - */ - static async getPositionForTrip(tripIdOrTrainNumber: string): Promise { - try { - const positions = await this.getAllPositions(); - - // Try direct lookup first - let position = positions.get(tripIdOrTrainNumber); - - // If not found, try extracting/matching train number - if (!position) { - const trainNumber = extractTrainNumber(tripIdOrTrainNumber); - if (trainNumber) { - position = positions.get(trainNumber); - } - } - - return position || null; - } catch (error) { - logger.error('Error fetching real-time position:', error); - return null; - } - } - - /** - * Get all current train positions from Transitdocs feed - */ - static async getAllPositions(): Promise> { - try { - // Check cache - const now = Date.now(); - if (positionsCache && now - positionsCache.timestamp < CACHE_TTL) { - return positionsCache.data; - } - - // Fetch fresh data (shared request avoids double-fetch when updates also need data) - const buffer = await fetchSharedProtobuf(); - const { positions, trainNumberMap } = parseVehiclePositions(buffer); - logger.info(`[Realtime] Fetched ${positions.size} vehicle positions`); - - // Also populate updates cache from the same buffer to avoid a second fetch - if (!updatesCache || now - updatesCache.timestamp >= CACHE_TTL) { - updatesCache = { data: parseTripUpdates(buffer, trainNumberMap), timestamp: now }; - } - - // Update cache - positionsCache = { data: positions, timestamp: now }; - return positions; - } catch (error) { - logger.error('Error fetching vehicle positions:', error); - // Return cached data if available, even if stale - return positionsCache?.data || new Map(); - } - } - - /** - * Get trip updates (delays) for a specific trip or train number - */ - static async getUpdatesForTrip(tripIdOrTrainNumber: string): Promise { - try { - const updates = await this.getAllUpdates(); - - // Try direct lookup first - let tripUpdates = updates.get(tripIdOrTrainNumber); - - // If not found, try extracting/matching train number - if (!tripUpdates) { - const trainNumber = extractTrainNumber(tripIdOrTrainNumber); - if (trainNumber) { - tripUpdates = updates.get(trainNumber); - } - } - - return tripUpdates || []; - } catch (error) { - logger.error('Error fetching trip updates:', error); - return []; - } - } - - /** - * Get all trip updates from Transitdocs feed - */ - static async getAllUpdates(): Promise> { - try { - // Check cache - const now = Date.now(); - if (updatesCache && now - updatesCache.timestamp < CACHE_TTL) { - return updatesCache.data; - } - - // Fetch fresh data (shared request avoids double-fetch when positions also need data) - const buffer = await fetchSharedProtobuf(); - const { positions, trainNumberMap } = parseVehiclePositions(buffer); - const updates = parseTripUpdates(buffer, trainNumberMap); - - // Also populate positions cache from the same buffer to avoid a second fetch - if (!positionsCache || now - positionsCache.timestamp >= CACHE_TTL) { - positionsCache = { data: positions, timestamp: now }; - } - - // Update cache - updatesCache = { data: updates, timestamp: now }; - return updates; - } catch (error) { - logger.error('Error fetching trip updates:', error); - // Return cached data if available, even if stale - return updatesCache?.data || new Map(); - } - } - - /** - * Get delay in minutes for a trip at a specific stop - */ - static async getDelayForStop(tripIdOrTrainNumber: string, stopId: string): Promise { - try { - const updates = await this.getUpdatesForTrip(tripIdOrTrainNumber); - const stopUpdate = updates.find(u => u.stop_id === stopId); - - if (stopUpdate && stopUpdate.departure_delay !== undefined) { - return Math.round(stopUpdate.departure_delay / 60); // Convert seconds to minutes - } - - return null; - } catch (error) { - logger.error('Error getting delay:', error); - return null; - } - } - - /** - * Get arrival delay in minutes for a trip at a specific stop. - * Returns arrival_delay with fallback to departure_delay (last stop has no departure). - */ - static async getArrivalDelayForStop(tripIdOrTrainNumber: string, stopId: string): Promise { - try { - const updates = await this.getUpdatesForTrip(tripIdOrTrainNumber); - const stopUpdate = updates.find(u => u.stop_id === stopId); - - if (stopUpdate) { - const delaySeconds = stopUpdate.arrival_delay ?? stopUpdate.departure_delay; - if (delaySeconds !== undefined) { - return Math.round(delaySeconds / 60); - } - } - - return null; - } catch (error) { - logger.error('Error getting arrival delay:', error); - return null; - } - } - - /** - * Get delays for all stops of a trip. - * Returns Map in minutes. - */ - static async getDelaysForAllStops( - tripIdOrTrainNumber: string - ): Promise> { - const result = new Map(); - try { - const updates = await this.getUpdatesForTrip(tripIdOrTrainNumber); - for (const u of updates) { - if (u.stop_id) { - result.set(u.stop_id, { - departureDelay: u.departure_delay != null ? Math.round(u.departure_delay / 60) : undefined, - arrivalDelay: u.arrival_delay != null ? Math.round(u.arrival_delay / 60) : undefined, - }); - } - } - } catch (error) { - logger.error('Error getting delays for all stops:', error); - } - return result; - } - - /** - * Format delay for display - */ - static formatDelay(delayMinutes: number | null): string { - if (delayMinutes === null || delayMinutes === 0) { - return 'On Time'; - } - if (delayMinutes > 0) { - return `Delayed ${delayMinutes}m`; - } - return `${Math.abs(delayMinutes)}m early`; - } - - /** - * Clear caches (useful for manual refresh) - */ - static clearCache(): void { - positionsCache = null; - updatesCache = null; - } - - /** - * Get all active trains with their current positions - * Returns an array of {trainNumber, position} for easy consumption - */ - static async getAllActiveTrains(): Promise> { - const positions = await this.getAllPositions(); - const trains: Array<{ trainNumber: string; position: RealtimePosition }> = []; - const seen = new Set(); - - for (const [key, position] of positions.entries()) { - const trainNumber = position.train_number || extractTrainNumber(key) || key; - if (!seen.has(trainNumber)) { - trains.push({ trainNumber, position }); - seen.add(trainNumber); - } - } - - return trains; - } -} diff --git a/apps/mobile/services/shape-loader.ts b/apps/mobile/services/shape-loader.ts deleted file mode 100644 index 2e56c6c..0000000 --- a/apps/mobile/services/shape-loader.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Shape Loader Service - * Manages efficient lazy-loading of rail route shapes based on viewport - * Uses bounding box indexing for fast spatial queries - */ - -import type { Shape, ViewportBounds } from '../types/train'; -import { debug, info } from '../utils/logger'; - -export interface ShapeBounds { - id: string; - minLat: number; - maxLat: number; - minLon: number; - maxLon: number; - pointCount: number; -} - -export interface VisibleShape { - id: string; - coordinates: Array<{ latitude: number; longitude: number }>; -} - -export class ShapeLoader { - private shapeBounds: Map = new Map(); - private shapeCoordinates: Map> = new Map(); - - /** - * Initialize shape loader with all shapes data - * Pre-computes bounding boxes for fast spatial queries - */ - initialize(shapes: Record): void { - this.shapeBounds.clear(); - this.shapeCoordinates.clear(); - - Object.entries(shapes).forEach(([shapeId, points]) => { - if (points.length === 0) return; - - // Compute bounding box - let minLat = points[0].shape_pt_lat; - let maxLat = points[0].shape_pt_lat; - let minLon = points[0].shape_pt_lon; - let maxLon = points[0].shape_pt_lon; - - const coordinates: Array<{ latitude: number; longitude: number }> = []; - - for (const point of points) { - minLat = Math.min(minLat, point.shape_pt_lat); - maxLat = Math.max(maxLat, point.shape_pt_lat); - minLon = Math.min(minLon, point.shape_pt_lon); - maxLon = Math.max(maxLon, point.shape_pt_lon); - - coordinates.push({ - latitude: point.shape_pt_lat, - longitude: point.shape_pt_lon, - }); - } - - this.shapeBounds.set(shapeId, { - id: shapeId, - minLat, - maxLat, - minLon, - maxLon, - pointCount: points.length, - }); - - this.shapeCoordinates.set(shapeId, coordinates); - }); - - const stats = this.getStats(); - info(`[ShapeLoader] Initialized: ${stats.totalShapes} shapes, ${stats.totalPoints} total points`); - } - - /** - * Get shapes visible in the given viewport with padding - * Adds padding to load shapes slightly outside viewport for smoother panning - */ - getVisibleShapes(viewport: ViewportBounds, paddingFraction: number = 0.3): VisibleShape[] { - const latPad = (viewport.maxLat - viewport.minLat) * paddingFraction; - const lonPad = (viewport.maxLon - viewport.minLon) * paddingFraction; - const paddedBounds = { - minLat: viewport.minLat - latPad, - maxLat: viewport.maxLat + latPad, - minLon: viewport.minLon - lonPad, - maxLon: viewport.maxLon + lonPad, - }; - - const visible: VisibleShape[] = []; - - // Query bounding boxes for intersection - for (const [shapeId, bounds] of this.shapeBounds) { - if (this.boundsIntersect(bounds, paddedBounds)) { - const coordinates = this.shapeCoordinates.get(shapeId); - if (coordinates) { - visible.push({ - id: shapeId, - coordinates, - }); - } - } - } - - return visible; - } - - /** - * Check if two bounding boxes intersect - */ - private boundsIntersect(bounds1: ShapeBounds, bounds2: ViewportBounds): boolean { - return !( - bounds1.maxLat < bounds2.minLat || - bounds1.minLat > bounds2.maxLat || - bounds1.maxLon < bounds2.minLon || - bounds1.minLon > bounds2.maxLon - ); - } - - /** - * Get all shapes without viewport filtering - */ - getAllShapes(): VisibleShape[] { - const all: VisibleShape[] = []; - for (const [shapeId, coordinates] of this.shapeCoordinates) { - all.push({ id: shapeId, coordinates }); - } - return all; - } - - /** - * Get statistics about loaded shapes - */ - getStats() { - let totalPoints = 0; - let maxPoints = 0; - let minPoints = Infinity; - - for (const bounds of this.shapeBounds.values()) { - totalPoints += bounds.pointCount; - maxPoints = Math.max(maxPoints, bounds.pointCount); - minPoints = Math.min(minPoints, bounds.pointCount); - } - - return { - totalShapes: this.shapeBounds.size, - totalPoints, - averagePointsPerShape: Math.round(totalPoints / (this.shapeBounds.size || 1)), - maxPointsInShape: maxPoints, - minPointsInShape: minPoints, - }; - } - - /** - * Clear all data - */ - clear(): void { - this.shapeBounds.clear(); - this.shapeCoordinates.clear(); - } -} - -// Export singleton instance -export const shapeLoader = new ShapeLoader(); diff --git a/apps/mobile/services/station-loader.ts b/apps/mobile/services/station-loader.ts index b77d4ed..f7345e6 100644 --- a/apps/mobile/services/station-loader.ts +++ b/apps/mobile/services/station-loader.ts @@ -1,11 +1,16 @@ /** - * Station Loader Service - * Manages efficient lazy-loading of station markers based on viewport - * Uses spatial indexing for fast viewport-based queries + * Thin synchronous facade over the API-client stop cache. + * + * Originally a spatial index initialized from the local GTFS dataset; now a + * shim that delegates to lookupStop so existing call sites + * (services/notifications.ts, services/storage.ts) keep their familiar + * `stationLoader.getStationByCode(code)` shape. lookupStop is itself sync — + * it returns whatever's in the api-client cache and fires a background + * fetch on miss — so behavior matches the old loader except entries + * populate lazily instead of all-at-once. */ -import type { Stop, ViewportBounds } from '../types/train'; -import { info } from '../utils/logger'; +import { lookupStop } from '../utils/api-stop-cache'; export interface StationBounds { id: string; @@ -16,82 +21,15 @@ export interface StationBounds { export interface VisibleStation extends StationBounds {} -export class StationLoader { - private stations: Map = new Map(); - - /** - * Initialize station loader with all stops data - * Stores station metadata for spatial queries - */ - initialize(stops: Stop[]): void { - this.stations.clear(); - - stops.forEach(stop => { - this.stations.set(stop.stop_id, { - id: stop.stop_id, - name: stop.stop_name, - lat: stop.stop_lat, - lon: stop.stop_lon, - }); - }); - - info(`[StationLoader] Initialized: ${this.stations.size} stations`); - } - - /** - * Get stations visible in the given viewport with padding - * Adds padding to load stations slightly outside viewport - */ - getVisibleStations(viewport: ViewportBounds, paddingFraction: number = 0.3): VisibleStation[] { - const latPad = (viewport.maxLat - viewport.minLat) * paddingFraction; - const lonPad = (viewport.maxLon - viewport.minLon) * paddingFraction; - const paddedBounds = { - minLat: viewport.minLat - latPad, - maxLat: viewport.maxLat + latPad, - minLon: viewport.minLon - lonPad, - maxLon: viewport.maxLon + lonPad, - }; - - const visible: VisibleStation[] = []; - - // Query stations within padded viewport - for (const station of this.stations.values()) { - if ( - station.lat >= paddedBounds.minLat && - station.lat <= paddedBounds.maxLat && - station.lon >= paddedBounds.minLon && - station.lon <= paddedBounds.maxLon - ) { - visible.push(station); - } - } - - return visible; - } - - /** - * Get statistics about loaded stations - */ - getStats() { +export const stationLoader = { + getStationByCode(code: string): StationBounds | undefined { + const stop = lookupStop(code); + if (!stop) return undefined; return { - totalStations: this.stations.size, + id: stop.stop_id, + name: stop.stop_name, + lat: stop.stop_lat, + lon: stop.stop_lon, }; - } - - /** - * Look up a station by its stop_id / code - */ - getStationByCode(code: string): StationBounds | undefined { - return this.stations.get(code); - } - - /** - * Clear all data - */ - clear(): void { - this.stations.clear(); - } -} - -// Export singleton instance -export const stationLoader = new StationLoader(); + }, +}; diff --git a/apps/mobile/types/api.ts b/apps/mobile/types/api.ts index c31d21e..7d6fbf9 100644 --- a/apps/mobile/types/api.ts +++ b/apps/mobile/types/api.ts @@ -149,3 +149,11 @@ export interface RealtimeUpdate { provider: string; positions: ApiTrainPosition[]; } + +export interface ApiActiveTrain { + provider: string; + tripId: string; + runDate: string; + trainNumber: string; + routeId: string; +} diff --git a/apps/mobile/utils/api-stop-cache.ts b/apps/mobile/utils/api-stop-cache.ts new file mode 100644 index 0000000..cde6b4e --- /dev/null +++ b/apps/mobile/utils/api-stop-cache.ts @@ -0,0 +1,52 @@ +/** + * Synchronous, API-backed shim that exposes the few read methods we used + * to lean on `gtfsParser` for. Each call returns immediately from the + * api-client cache and fires a background fetch on miss; components that + * call these inside render should also subscribe to cache changes via + * `useApiCacheVersion` so they re-render when new data arrives. + * + * Multi-provider note: until the app supports per-tap provider tracking, + * all lookups assume Amtrak. Pass providerId explicitly when the caller + * already knows the namespace. + */ + +import { + getCachedAgency, + getCachedStop, + prefetchAgency, + prefetchStop, +} from '../services/api-client'; +import type { Stop } from '../types/train'; + +const DEFAULT_PROVIDER = 'amtrak'; +const DEFAULT_TIMEZONE = 'America/New_York'; + +function splitNamespaced(stopId: string): { provider: string; code: string } { + const i = stopId.indexOf(':'); + if (i <= 0) return { provider: DEFAULT_PROVIDER, code: stopId }; + return { provider: stopId.slice(0, i), code: stopId.slice(i + 1) }; +} + +export function lookupStop(stopId: string | null | undefined): Stop | undefined { + if (!stopId) return undefined; + const { provider, code } = splitNamespaced(stopId); + prefetchStop(provider, code); + const s = getCachedStop(provider, code); + if (!s) return undefined; + return { + stop_id: s.code, + stop_name: s.name, + stop_lat: s.lat, + stop_lon: s.lon, + stop_timezone: s.timezone ?? undefined, + }; +} + +export function lookupStopName(stopId: string | null | undefined): string { + return lookupStop(stopId)?.stop_name ?? stopId ?? ''; +} + +export function lookupAgencyTimezone(providerId: string = DEFAULT_PROVIDER): string { + prefetchAgency(providerId); + return getCachedAgency(providerId)?.timezone ?? DEFAULT_TIMEZONE; +} diff --git a/apps/mobile/utils/gtfs-parser.ts b/apps/mobile/utils/gtfs-parser.ts deleted file mode 100644 index 6ccc9e0..0000000 --- a/apps/mobile/utils/gtfs-parser.ts +++ /dev/null @@ -1,841 +0,0 @@ -/** - * GTFS data parser for Amtrak trains - * Data is populated dynamically via gtfs-sync service - no bundled fallback data - */ - -import type { CalendarDateException, CalendarEntry, EnrichedStopTime, Route, SearchResult, Shape, Stop, StopTime, Trip } from '../types/train'; -import { debug, info, warn } from './logger'; -import { getCurrentMinutesInTimezone, getTimezoneForStop } from './timezone'; - -export class GTFSParser { - private routes: Map = new Map(); - private stops: Map = new Map(); - private stopTimes: Map = new Map(); - private shapes: Map = new Map(); - private trips: Map = new Map(); // keyed by trip_id - private tripsByNumber: Map = new Map(); // keyed by trip_short_name for search - private calendarEntries: Map = new Map(); // keyed by service_id - private calendarDateExceptions: Map> = new Map(); // service_id -> (date -> exception_type) - private hasCalendarData: boolean = false; - private _isLoaded: boolean = false; - private _agencyTimezone: string | null = null; - private _onLoadedListeners: Array<() => void> = []; - private _onShapesUpdatedListeners: Array<() => void> = []; - - constructor() { - // Parser starts empty - data is loaded dynamically via overrideData() - } - - get isLoaded(): boolean { - return this._isLoaded; - } - - /** Subscribe to be notified when GTFS data finishes loading. Fires immediately if already loaded. */ - onLoaded(listener: () => void): () => void { - if (this._isLoaded) { - listener(); - return () => {}; - } - this._onLoadedListeners.push(listener); - return () => { - this._onLoadedListeners = this._onLoadedListeners.filter(l => l !== listener); - }; - } - - /** Subscribe to be notified when shapes data is updated (e.g. deferred load). Fires immediately if shapes already loaded. */ - onShapesUpdated(listener: () => void): () => void { - if (this.shapes.size > 0) { - listener(); - return () => {}; - } - this._onShapesUpdatedListeners.push(listener); - return () => { - this._onShapesUpdatedListeners = this._onShapesUpdatedListeners.filter(l => l !== listener); - }; - } - - /** IANA timezone for GTFS schedule times (from agency.txt agency_timezone). - * Falls back to America/New_York (Amtrak's agency timezone) if not yet loaded. */ - get agencyTimezone(): string { - return this._agencyTimezone || 'America/New_York'; - } - - // Override parser data with dynamically fetched cache - overrideData( - routes: Route[], - stops: Stop[], - stopTimes: Record, - shapes: Record = {}, - trips: Trip[] = [], - calendar: CalendarEntry[] = [], - calendarDates: CalendarDateException[] = [], - agencyTimezone: string | null = null, - ): void { - this.routes.clear(); - this.stops.clear(); - this.stopTimes.clear(); - this.shapes.clear(); - this.trips.clear(); - this.tripsByNumber.clear(); - this.calendarEntries.clear(); - this.calendarDateExceptions.clear(); - - routes.forEach(route => { - if (route && route.route_id) this.routes.set(route.route_id, route); - }); - stops.forEach(stop => { - if (stop && stop.stop_id) this.stops.set(stop.stop_id, stop); - }); - Object.entries(stopTimes).forEach(([tripId, times]) => { - if (tripId && Array.isArray(times)) this.stopTimes.set(tripId, times); - }); - Object.entries(shapes).forEach(([shapeId, points]) => { - if (shapeId && Array.isArray(points)) this.shapes.set(shapeId, points); - }); - // Populate trips maps - trips.forEach(trip => { - if (trip && trip.trip_id) { - this.trips.set(trip.trip_id, trip); - // Also index by trip_short_name for search - if (trip.trip_short_name) { - const existing = this.tripsByNumber.get(trip.trip_short_name) || []; - existing.push(trip); - this.tripsByNumber.set(trip.trip_short_name, existing); - } - } - }); - - // Populate calendar maps - calendar.forEach(entry => { - if (entry && entry.service_id) { - this.calendarEntries.set(entry.service_id, entry); - } - }); - calendarDates.forEach(exception => { - if (exception && exception.service_id) { - let dateMap = this.calendarDateExceptions.get(exception.service_id); - if (!dateMap) { - dateMap = new Map(); - this.calendarDateExceptions.set(exception.service_id, dateMap); - } - dateMap.set(exception.date, exception.exception_type); - } - }); - this.hasCalendarData = this.calendarEntries.size > 0 || this.calendarDateExceptions.size > 0; - this._agencyTimezone = agencyTimezone || null; - - this._isLoaded = this.routes.size > 0 && this.stops.size > 0; - - debug(`[GTFSParser] Data loaded: ${this.routes.size} routes, ${this.stops.size} stops, ${this.stopTimes.size} trips, ${this.trips.size} trip records, ${this.shapes.size} shapes`); - if (this.hasCalendarData) { - debug(`[GTFSParser] Calendar: ${this.calendarEntries.size} entries, ${this.calendarDateExceptions.size} exception sets`); - } - if (!this._isLoaded) { - warn('[GTFSParser] Data loaded but parser reports not ready - routes or stops may be empty'); - } - - // Notify listeners - if (this._isLoaded) { - for (const listener of this._onLoadedListeners) { - listener(); - } - this._onLoadedListeners = []; - } - // Notify shapes listeners if shapes were provided (fresh download path) - if (this.shapes.size > 0) { - for (const listener of this._onShapesUpdatedListeners) { - listener(); - } - this._onShapesUpdatedListeners = []; - } - } - - /** Update only shapes data (used for deferred shape loading) */ - updateShapes(shapes: Record): void { - this.shapes.clear(); - Object.entries(shapes).forEach(([shapeId, points]) => { - if (shapeId && Array.isArray(points)) this.shapes.set(shapeId, points); - }); - debug(`[GTFSParser] Shapes updated: ${this.shapes.size} shapes`); - for (const listener of this._onShapesUpdatedListeners) { - listener(); - } - this._onShapesUpdatedListeners = []; - } - - getRouteName(routeId: string): string { - return this.routes.get(routeId)?.route_long_name || 'Unknown Route'; - } - - getStopName(stopId: string): string { - return this.stops.get(stopId)?.stop_name || stopId; - } - - getStopCode(stopId: string): string { - return stopId; - } - - getStop(stopId: string): Stop | undefined { - return this.stops.get(stopId); - } - - getRoute(routeId: string): Route | undefined { - return this.routes.get(routeId); - } - - getIntermediateStops(tripId: string): EnrichedStopTime[] { - const times = this.stopTimes.get(tripId) || []; - // Filter out first and last stops, return intermediate stops - return times - .slice(1, -1) - .map(time => ({ - ...time, - stop_name: this.getStopName(time.stop_id), - stop_code: time.stop_id, - })) - .sort((a, b) => a.stop_sequence - b.stop_sequence); - } - - getStopTimesForTrip(tripId: string): EnrichedStopTime[] { - const times = this.stopTimes.get(tripId) || []; - return times - .map(time => ({ - ...time, - stop_name: this.getStopName(time.stop_id), - stop_code: time.stop_id, - })) - .sort((a, b) => a.stop_sequence - b.stop_sequence); - } - - /** - * Check if a service_id is active on a given date. - * Returns true if no calendar data is loaded (backwards compatible fallback). - */ - isServiceActiveOnDate(serviceId: string, date: Date): boolean { - if (!this.hasCalendarData) return true; - if (!serviceId) return true; - - // Convert date to YYYYMMDD integer for fast comparison - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const dateInt = year * 10000 + month * 100 + day; - - // Check calendar_dates exceptions first (they override base schedule) - const exceptions = this.calendarDateExceptions.get(serviceId); - if (exceptions) { - const exceptionType = exceptions.get(dateInt); - if (exceptionType === 1) return true; // explicitly added - if (exceptionType === 2) return false; // explicitly removed - } - - // Check base calendar schedule - const entry = this.calendarEntries.get(serviceId); - if (!entry) return false; // no calendar entry and no exception = not active - - // Check date range - if (dateInt < entry.start_date || dateInt > entry.end_date) return false; - - // Check day of week (JS: 0=Sun, 1=Mon, ..., 6=Sat) - const dayOfWeek = date.getDay(); - switch (dayOfWeek) { - case 0: return entry.sunday; - case 1: return entry.monday; - case 2: return entry.tuesday; - case 3: return entry.wednesday; - case 4: return entry.thursday; - case 5: return entry.friday; - case 6: return entry.saturday; - default: return false; - } - } - - getTripsForStop(stopId: string, date?: Date): string[] { - const trips: string[] = []; - const seen = new Set(); - this.stopTimes.forEach((times, tripId) => { - if (times.some(t => t.stop_id === stopId) && !seen.has(tripId)) { - // If date provided, filter by service calendar - if (date) { - const trip = this.trips.get(tripId); - if (trip && !this.isServiceActiveOnDate(trip.service_id, date)) return; - } - trips.push(tripId); - seen.add(tripId); - } - }); - return trips; - } - - getAllRoutes(): Route[] { - return Array.from(this.routes.values()); - } - - getAllStops(): Stop[] { - return Array.from(this.stops.values()); - } - - getAllTripIds(): string[] { - return Array.from(this.stopTimes.keys()); - } - - /** - * Get trip by trip_id - */ - getTrip(tripId: string): Trip | undefined { - return this.trips.get(tripId); - } - - /** - * Get the actual train number (trip_short_name) for a trip_id - * Falls back to extracting from trip_id if not found - * Returns null if no valid train number can be determined - */ - getTrainNumber(tripId: string): string | null { - const trip = this.trips.get(tripId); - if (trip?.trip_short_name) return trip.trip_short_name; - const match = tripId.match(/_(\d{1,4})$/); - if (match) return match[1]; - return null; - } - - /** - * Get route_id for a trip_id - */ - getRouteIdForTrip(tripId: string): string | undefined { - return this.trips.get(tripId)?.route_id; - } - - /** - * Search trips by train number (trip_short_name) - */ - getTripsByNumber(trainNumber: string): Trip[] { - return this.tripsByNumber.get(trainNumber) || []; - } - - /** - * Get all trips - */ - getAllTrips(): Trip[] { - return Array.from(this.trips.values()); - } - - getRawShapesData(): Record { - const result: Record = {}; - this.shapes.forEach((points, shapeId) => { - result[shapeId] = points; - }); - return result; - } - - search(query: string): SearchResult[] { - const results: SearchResult[] = []; - const queryLower = query.toLowerCase(); - // Support searching by AMT{train}, {name}{train}, or just {train} - let trainNumberQuery = ''; - // If query starts with 'amt', treat as AMT{train} - if (queryLower.startsWith('amt')) { - trainNumberQuery = queryLower.substring(3); - } else if (/^\d+$/.test(query)) { - // Pure number query - search by actual train number - trainNumberQuery = query; - } else { - // Try to match {name}{train} pattern (e.g., acela2150, crescent19) - const nameTrainMatch = queryLower.match(/^([a-z]+)(\d{1,4})$/); - if (nameTrainMatch) { - trainNumberQuery = nameTrainMatch[2]; - } - } - - // Search stops (stations) - this.stops.forEach(stop => { - if (stop.stop_name.toLowerCase().includes(queryLower)) { - results.push({ - id: `stop-name-${stop.stop_id}`, - name: stop.stop_name, - subtitle: `Name contains "${query}"`, - type: 'station', - data: stop, - }); - } - // Match by stop ID (abbreviation) - else if (stop.stop_id.toLowerCase().includes(queryLower)) { - results.push({ - id: `stop-id-${stop.stop_id}`, - name: stop.stop_name, - subtitle: `Station matches "${stop.stop_id}"`, - type: 'station', - data: stop, - }); - } - }); - - // Search routes - this.routes.forEach(route => { - if ( - route.route_long_name.toLowerCase().includes(queryLower) || - route.route_short_name?.toLowerCase().includes(queryLower) || - route.route_id.toLowerCase().includes(queryLower) - ) { - results.push({ - id: `route-${route.route_id}`, - name: route.route_long_name, - subtitle: `AMT${route.route_id}`, - type: 'route', - data: route, - }); - } - }); - - // Search by actual train number using trips data - if (trainNumberQuery) { - const matchingTrips = this.tripsByNumber.get(trainNumberQuery) || []; - for (const trip of matchingTrips.slice(0, 5)) { - const routeName = this.getRouteName(trip.route_id); - const displayName = - routeName !== 'Unknown Route' ? `${routeName} ${trip.trip_short_name}` : `Train ${trip.trip_short_name}`; - results.push({ - id: `train-${trip.trip_id}`, - name: displayName, - subtitle: trip.trip_headsign || '', - type: 'train', - data: { trip_id: trip.trip_id }, - }); - } - } - - // Search trips (trains) by their stops - this.stopTimes.forEach((times, tripId) => { - const trainNumber = this.getTrainNumber(tripId) || tripId; - const trip = this.trips.get(tripId); - const routeName = trip?.route_id ? this.getRouteName(trip.route_id) : ''; - const displayName = - routeName && routeName !== 'Unknown Route' ? `${routeName} ${trainNumber}` : `Train ${trainNumber}`; - - // Check for AMT{train} or {name}{train} match (legacy support) - const tripIdLower = tripId.toLowerCase(); - if ( - trainNumberQuery && - tripIdLower.endsWith(trainNumberQuery) && - !results.find(r => r.id === `train-${tripId}`) - ) { - results.push({ - id: `tripid-${tripId}`, - name: displayName, - subtitle: trip?.trip_headsign || '', - type: 'train', - data: { trip_id: tripId }, - }); - } - const uniqueStops = new Set(times.map(t => t.stop_id)); - uniqueStops.forEach(stopId => { - const stop = this.stops.get(stopId); - if (stop && stop.stop_name.toLowerCase().includes(queryLower)) { - results.push({ - id: `trip-stop-${tripId}-${stopId}`, - name: displayName, - subtitle: `Stops at "${stop.stop_name}"`, - type: 'train', - data: { trip_id: tripId, stop_id: stopId, stop_name: stop.stop_name }, - }); - } - }); - }); - - // Remove duplicates and limit results - const seen = new Set(); - const filtered = results - .filter(result => { - if (seen.has(result.id)) return false; - seen.add(result.id); - return true; - }) - .slice(0, 20); // Limit to 20 results - debug(`[GTFSParser] search("${query}"): ${filtered.length} results`); - return filtered; - } - - getShape(shapeId: string): Shape[] | undefined { - return this.shapes.get(shapeId); - } - - getAllShapeIds(): string[] { - return Array.from(this.shapes.keys()); - } - - // Get all shapes as polyline coordinates for map rendering - getShapesForMap(): Array<{ id: string; coordinates: Array<{ latitude: number; longitude: number }> }> { - const result: Array<{ id: string; coordinates: Array<{ latitude: number; longitude: number }> }> = []; - this.shapes.forEach((points, shapeId) => { - result.push({ - id: shapeId, - coordinates: points.map(p => ({ - latitude: p.shape_pt_lat, - longitude: p.shape_pt_lon, - })), - }); - }); - return result; - } - - /** - * Unified search returning categorized results for the initial search bar. - * Returns trains, routes, and stations in separate arrays. - */ - searchUnified(query: string): { trains: SearchResult[]; routes: SearchResult[]; stations: SearchResult[] } { - const queryLower = query.toLowerCase().trim(); - if (!queryLower) return { trains: [], routes: [], stations: [] }; - - const trains: SearchResult[] = []; - const routes: SearchResult[] = []; - const stations: SearchResult[] = []; - - // --- Train number matching --- - // Extract a numeric portion for train number prefix matching - let trainNumberQuery = ''; - if (queryLower.startsWith('amt')) { - trainNumberQuery = queryLower.substring(3); - } else if (/^\d+$/.test(queryLower)) { - trainNumberQuery = queryLower; - } else { - const nameTrainMatch = queryLower.match(/^([a-z]+)(\d{1,4})$/); - if (nameTrainMatch) { - trainNumberQuery = nameTrainMatch[2]; - } - } - - if (trainNumberQuery) { - // Prefix match: "21" matches 21, 210, 2150, etc. - const seenNumbers = new Set(); - this.tripsByNumber.forEach((trips, trainNum) => { - if (trainNum.startsWith(trainNumberQuery) && !seenNumbers.has(trainNum)) { - seenNumbers.add(trainNum); - const trip = trips[0]; - if (trip) { - const routeName = this.getRouteName(trip.route_id); - const displayName = routeName !== 'Unknown Route' - ? `${routeName} ${trip.trip_short_name}` - : `Train ${trip.trip_short_name}`; - trains.push({ - id: `train-num-${trainNum}`, - name: displayName, - subtitle: trip.trip_headsign || '', - type: 'train', - data: trip, - }); - } - } - }); - } - - // Also match train numbers where the route name matches the alpha part - if (queryLower.match(/^[a-z]/)) { - const seenNumbers = new Set(trains.map(t => { - const trip = t.data as Trip; - return trip.trip_short_name || ''; - })); - this.tripsByNumber.forEach((trips, trainNum) => { - if (seenNumbers.has(trainNum)) return; - const trip = trips[0]; - if (!trip) return; - const routeName = this.getRouteName(trip.route_id).toLowerCase(); - const displayName = routeName !== 'unknown route' - ? `${this.getRouteName(trip.route_id)} ${trip.trip_short_name}` - : `Train ${trip.trip_short_name}`; - // Match if route name starts with query or query matches "routename + number" - if (routeName.startsWith(queryLower) || displayName.toLowerCase().includes(queryLower)) { - seenNumbers.add(trainNum); - trains.push({ - id: `train-num-${trainNum}`, - name: displayName, - subtitle: trip.trip_headsign || '', - type: 'train', - data: trip, - }); - } - }); - } - - // --- Route matching --- - this.routes.forEach(route => { - if ( - route.route_long_name.toLowerCase().includes(queryLower) || - route.route_short_name?.toLowerCase().includes(queryLower) - ) { - routes.push({ - id: `route-${route.route_id}`, - name: route.route_long_name, - subtitle: route.route_short_name || '', - type: 'route', - data: route, - }); - } - }); - - // --- Station matching --- - this.stops.forEach(stop => { - if ( - stop.stop_name.toLowerCase().includes(queryLower) || - stop.stop_id.toLowerCase().includes(queryLower) - ) { - stations.push({ - id: `stop-${stop.stop_id}`, - name: stop.stop_name, - subtitle: stop.stop_id, - type: 'station', - data: stop, - }); - } - }); - - return { - trains: trains.slice(0, 5), - routes: routes.slice(0, 5), - stations: stations.slice(0, 8), - }; - } - - /** - * Given a train number and date, find the specific Trip active on that date. - * Falls back to the first trip for the train number if no calendar match. - */ - getTripForTrainOnDate(trainNumber: string, date: Date): Trip | undefined { - const trips = this.tripsByNumber.get(trainNumber); - if (!trips || trips.length === 0) return undefined; - - // Try to find a trip whose service is active on the given date - const activeTrip = trips.find(trip => this.isServiceActiveOnDate(trip.service_id, date)); - return activeTrip || trips[0]; - } - - /** - * Get the GTFS calendar date range and service-day check for a given train number. - * Used to constrain date pickers and show live warnings for non-service days. - */ - getServiceInfoForTrain(trainNumber: string): { minDate: Date; maxDate: Date } | null { - const trips = this.tripsByNumber.get(trainNumber); - if (!trips || trips.length === 0 || !this.hasCalendarData) return null; - - let earliest = Infinity; - let latest = -Infinity; - - for (const trip of trips) { - const entry = this.calendarEntries.get(trip.service_id); - if (entry) { - if (entry.start_date < earliest) earliest = entry.start_date; - if (entry.end_date > latest) latest = entry.end_date; - } - } - - if (earliest === Infinity || latest === -Infinity) return null; - - // Convert YYYYMMDD integers to Date objects - const toDate = (d: number) => { - const year = Math.floor(d / 10000); - const month = Math.floor((d % 10000) / 100) - 1; - const day = d % 100; - return new Date(year, month, day); - }; - - return { minDate: toDate(earliest), maxDate: toDate(latest) }; - } - - /** - * Get all unique train numbers (trip_short_name) that belong to a given route_id. - * Returns array of { trainNumber, displayName, headsign }. - */ - getTrainNumbersForRoute(routeId: string): Array<{ trainNumber: string; displayName: string; headsign: string; endpointLabel: string }> { - const results: Array<{ trainNumber: string; displayName: string; headsign: string; endpointLabel: string }> = []; - const seen = new Set(); - const routeName = this.getRouteName(routeId); - - this.tripsByNumber.forEach((trips, trainNum) => { - if (seen.has(trainNum)) return; - const trip = trips.find(t => t.route_id === routeId); - if (trip) { - seen.add(trainNum); - const displayName = routeName !== 'Unknown Route' - ? `${routeName} ${trainNum}` - : `Train ${trainNum}`; - // Get first/last stop abbreviations - let endpointLabel = ''; - const stopTimes = this.stopTimes.get(trip.trip_id); - if (stopTimes && stopTimes.length >= 2) { - const sorted = [...stopTimes].sort((a, b) => a.stop_sequence - b.stop_sequence); - endpointLabel = `${sorted[0].stop_id} → ${sorted[sorted.length - 1].stop_id}`; - } - results.push({ - trainNumber: trainNum, - displayName, - headsign: trip.trip_headsign || '', - endpointLabel, - }); - } - }); - - // Sort by train number numerically - results.sort((a, b) => { - const numA = parseInt(a.trainNumber, 10); - const numB = parseInt(b.trainNumber, 10); - if (!isNaN(numA) && !isNaN(numB)) return numA - numB; - return a.trainNumber.localeCompare(b.trainNumber); - }); - - return results; - } - - /** - * Get unique routes that serve a given stop (by scanning stop_times). - */ - getRoutesServingStop(stopId: string): Route[] { - const routeIds = new Set(); - this.stopTimes.forEach((times, tripId) => { - if (routeIds.size >= 10) return; // enough - if (times.some(t => t.stop_id === stopId)) { - const trip = this.trips.get(tripId); - if (trip) routeIds.add(trip.route_id); - } - }); - const routes: Route[] = []; - for (const rid of routeIds) { - const route = this.routes.get(rid); - if (route) routes.push(route); - } - return routes; - } - - /** - * Get upcoming trains departing from a stop today. - * Returns trips sorted by departure time, filtered to active services and future departures. - */ - getUpcomingTrainsFromStop(stopId: string, limit = 2): Array<{ trip: Trip; departureTime: string; trainNumber: string; routeName: string }> { - const now = new Date(); - // GTFS stop times are in the agency timezone, so compare "now" in that timezone - const nowMinutes = getCurrentMinutesInTimezone(this._agencyTimezone); - const results: Array<{ trip: Trip; departureTime: string; trainNumber: string; routeName: string; depMinutes: number }> = []; - const seenTrainNumbers = new Set(); - - this.stopTimes.forEach((times, tripId) => { - const stopTime = times.find(t => t.stop_id === stopId); - if (!stopTime) return; - - const trip = this.trips.get(tripId); - if (!trip) return; - if (!this.isServiceActiveOnDate(trip.service_id, now)) return; - - const [hStr, mStr] = stopTime.departure_time.split(':'); - const depMinutes = parseInt(hStr, 10) * 60 + parseInt(mStr, 10); - if (depMinutes <= nowMinutes) return; // already departed - - const trainNumber = trip.trip_short_name || tripId; - if (seenTrainNumbers.has(trainNumber)) return; - seenTrainNumbers.add(trainNumber); - - results.push({ - trip, - departureTime: stopTime.departure_time, - trainNumber, - routeName: this.getRouteName(trip.route_id), - depMinutes, - }); - }); - - results.sort((a, b) => a.depMinutes - b.depMinutes); - return results.slice(0, limit).map(({ trip, departureTime, trainNumber, routeName }) => ({ - trip, departureTime, trainNumber, routeName, - })); - } - - /** - * Search for stations only (for the two-station search flow) - */ - searchStations(query: string): Stop[] { - const queryLower = query.toLowerCase(); - const results: Stop[] = []; - - this.stops.forEach(stop => { - if (stop.stop_name.toLowerCase().includes(queryLower) || stop.stop_id.toLowerCase().includes(queryLower)) { - results.push(stop); - } - }); - - return results.slice(0, 10); - } - - /** - * Find all trips that stop at both stations in sequence (fromStop before toStop) - */ - findTripsWithStops( - fromStopId: string, - toStopId: string, - date?: Date - ): Array<{ - tripId: string; - fromStop: EnrichedStopTime; - toStop: EnrichedStopTime; - intermediateStops: EnrichedStopTime[]; - }> { - const results: Array<{ - tripId: string; - fromStop: EnrichedStopTime; - toStop: EnrichedStopTime; - intermediateStops: EnrichedStopTime[]; - }> = []; - - this.stopTimes.forEach((times, tripId) => { - // If date provided, filter by service calendar - if (date) { - const trip = this.trips.get(tripId); - if (trip && !this.isServiceActiveOnDate(trip.service_id, date)) return; - } - - const fromIdx = times.findIndex(t => t.stop_id === fromStopId); - const toIdx = times.findIndex(t => t.stop_id === toStopId); - - // Both stops must exist and fromStop must come before toStop - if (fromIdx !== -1 && toIdx !== -1 && fromIdx < toIdx) { - const fromStop = times[fromIdx]; - const toStop = times[toIdx]; - const intermediateStops = times.slice(fromIdx + 1, toIdx); - - results.push({ - tripId, - fromStop: { - ...fromStop, - stop_name: this.getStopName(fromStop.stop_id), - stop_code: fromStop.stop_id, - }, - toStop: { - ...toStop, - stop_name: this.getStopName(toStop.stop_id), - stop_code: toStop.stop_id, - }, - intermediateStops: intermediateStops.map(s => ({ - ...s, - stop_name: this.getStopName(s.stop_id), - stop_code: s.stop_id, - })), - }); - } - }); - - // Sort by departure time - results.sort((a, b) => a.fromStop.departure_time.localeCompare(b.fromStop.departure_time)); - - // Deduplicate by train number + departure time (same train on different days has different trip_ids) - const seen = new Set(); - const deduped = results.filter(result => { - const trip = this.trips.get(result.tripId); - const trainNumber = trip?.trip_short_name || result.tripId; - const key = `${trainNumber}-${result.fromStop.departure_time}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - debug(`[GTFSParser] findTripsWithStops(${fromStopId} → ${toStopId}): ${deduped.length} trips found`); - return deduped; - } -} - -// Export singleton instance -export const gtfsParser = new GTFSParser(); diff --git a/apps/mobile/utils/timezone.ts b/apps/mobile/utils/timezone.ts index d2c6a95..860da1f 100644 --- a/apps/mobile/utils/timezone.ts +++ b/apps/mobile/utils/timezone.ts @@ -1,5 +1,6 @@ import tzlookup from '@photostructure/tz-lookup'; import type { Stop } from '../types/train'; +import { lookupAgencyTimezone, lookupStop } from './api-stop-cache'; import { formatTimeWithDayOffset, type FormattedTime } from './time-formatting'; import { logger } from './logger'; @@ -149,16 +150,14 @@ export function convertGtfsTimeToLocal( /** * Convenience: convert a GTFS time to local timezone for a given stop code. - * Looks up the stop via gtfsParser and derives its timezone. - * Falls back to formatTimeWithDayOffset if stop not found. + * Falls back to formatTimeWithDayOffset if the stop isn't yet in the API + * cache (a fetch is fired in the background by lookupStop). */ export function convertGtfsTimeForStop(gtfsTime24: string, stopCode: string): FormattedTime { - // Lazy import to avoid circular dependency - const { gtfsParser } = require('./gtfs-parser'); - const stop = gtfsParser.getStop(stopCode); + const stop = lookupStop(stopCode); if (!stop) { return formatTimeWithDayOffset(gtfsTime24); } const stopTz = getTimezoneForStop(stop); - return convertGtfsTimeToLocal(gtfsTime24, gtfsParser.agencyTimezone, stopTz); + return convertGtfsTimeToLocal(gtfsTime24, lookupAgencyTimezone(), stopTz); } diff --git a/apps/mobile/utils/train-display.ts b/apps/mobile/utils/train-display.ts index 5454222..05b5882 100644 --- a/apps/mobile/utils/train-display.ts +++ b/apps/mobile/utils/train-display.ts @@ -2,8 +2,8 @@ * Shared display/formatting utilities for train UI components. */ import type { Train } from '../types/train'; +import { lookupAgencyTimezone, lookupStop } from './api-stop-cache'; import { parseTimeToMinutes, timeToMinutes } from './time-formatting'; -import { gtfsParser } from './gtfs-parser'; import { getCurrentSecondsInTimezone, getTimezoneForStop } from './timezone'; /** @@ -19,8 +19,8 @@ export function getCountdownForTrain(train: Train): { const days = Math.round(train.daysAway); return { value: days, unit: days === 1 ? 'DAY' : 'DAYS', past: false }; } - const fromStop = gtfsParser.getStop(train.fromCode); - const fromTz = fromStop ? getTimezoneForStop(fromStop) : gtfsParser.agencyTimezone; + const fromStop = lookupStop(train.fromCode); + const fromTz = fromStop ? getTimezoneForStop(fromStop) : lookupAgencyTimezone(); const nowSec = getCurrentSecondsInTimezone(fromTz); const departSec = parseTimeToMinutes(train.departTime) * 60 + (train.realtime?.delay && train.realtime.delay > 0 ? train.realtime.delay * 60 : 0); diff --git a/apps/mobile/utils/train-helpers.ts b/apps/mobile/utils/train-helpers.ts index f146eb6..face00d 100644 --- a/apps/mobile/utils/train-helpers.ts +++ b/apps/mobile/utils/train-helpers.ts @@ -3,7 +3,7 @@ * Consolidated from services/api.ts and services/realtime.ts */ -import { gtfsParser } from './gtfs-parser'; +import { getCachedTrip, prefetchTrip } from '../services/api-client'; import { parseTimeToDate } from './time-formatting'; import type { Train } from '../types/train'; @@ -41,11 +41,13 @@ export function isLikelyTrainNumber(s: string): boolean { * extractTrainNumber("248766") // null (opaque database ID) */ export function extractTrainNumber(tripId: string): string | null { - // 1. GTFS static data (source of truth) - const fromGtfs = gtfsParser.getTrainNumber(tripId); - if (fromGtfs && fromGtfs !== tripId && isLikelyTrainNumber(fromGtfs)) { - return fromGtfs; + // 1. API cache (source of truth when available); fire a fetch on miss so a + // later call sees it. + const cached = getCachedTrip(tripId); + if (cached?.shortName && isLikelyTrainNumber(cached.shortName)) { + return cached.shortName; } + if (!cached) prefetchTrip(tripId); // 2. Structured: trailing number after underscore (YYYY-MM-DD_AMTK_543) const underscoreMatch = tripId.match(/_(\d{1,4})$/); From 7a13b45486c6ab53bf3dae01b737d775ee14b356 Mon Sep 17 00:00:00 2001 From: Mootbing <50122069+Mootbing@users.noreply.github.com> Date: Mon, 11 May 2026 18:42:16 -0700 Subject: [PATCH 09/10] Update highway colors to grey (#42) * Update map styles to Apple Maps-inspired light and dark themes Replace OpenFreeMap liberty styles with custom Apple Maps-inspired color palettes for both light and dark modes. Co-Authored-By: Claude Opus 4.6 (1M context) * Update highway colors from gold to grey in light and dark map styles Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Jason Xu Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Jason Xu Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mr-Technician <26885142+Mr-Technician@users.noreply.github.com> --- apps/mobile/assets/apple-dark-style.json | 2 +- apps/mobile/assets/apple-light-style.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/assets/apple-dark-style.json b/apps/mobile/assets/apple-dark-style.json index a7248e5..191d42c 100644 --- a/apps/mobile/assets/apple-dark-style.json +++ b/apps/mobile/assets/apple-dark-style.json @@ -1 +1 @@ -{"version":8,"name":"Apple Dark","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#1c1c1e"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#1a2e1a","fill-opacity":0.7}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#222224"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#1a2e1a","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#1e301c","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#2a3438","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#2e2a20"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#1e301c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#1e2a1c"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#2a2024"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#28261e"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#152838","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#152838"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#28282a","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4a3a10","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2a2a2c","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#5a4818","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#6a5820","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#7a6828","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#28262a","fill-outline-color":"#38363a"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#28262a","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#4a3860","line-dasharray":[1,1],"line-width":["interpolate",["linear"],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4870","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#5a4870","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#98989d","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#78787c","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#98989d","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#b0b0b4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#c0c0c4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}}]} \ No newline at end of file +{"version":8,"name":"Apple Dark","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#1c1c1e"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#1a2e1a","fill-opacity":0.7}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#222224"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#1a2e1a","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#1e301c","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#2a3438","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#2e2a20"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#1e301c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#1e2a1c"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#2a2024"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#28261e"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#152838","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#152838","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#152838"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#28282a","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e32","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e32","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e32","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#4e4e56","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#3a3a3c","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2a2a2c","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#2a2a2c","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383e","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#2e2e30","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#38383a","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#3a3a3c","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#44444a","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#4e4e56","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#48484a","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#28262a","fill-outline-color":"#38363a"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#28262a","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#4a3860","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#5a4870","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#5a4870","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#3a6888","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#98989d","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#78787c","text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#aeaeb2","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(28,28,30,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#8e8ea0","text-halo-blur":0.5,"text-halo-color":"rgba(28,28,30,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#98989d","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#b0b0b4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#c0c0c4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#d0d0d4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#e0e0e4","text-halo-blur":1,"text-halo-color":"rgba(28,28,30,0.9)","text-halo-width":1}}]} \ No newline at end of file diff --git a/apps/mobile/assets/apple-light-style.json b/apps/mobile/assets/apple-light-style.json index 81b85ce..8a0496b 100644 --- a/apps/mobile/assets/apple-light-style.json +++ b/apps/mobile/assets/apple-light-style.json @@ -1 +1 @@ -{"version":8,"name":"Apple Light","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f5f0"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#c8e6a0","fill-opacity":0.6}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#f2efe9"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#c8dfab","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#d4e8b8","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#e8f0f4","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#f5ebd6"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#b8d88c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#d4e2c8"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#f8e8e8"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#f2ecd8"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a8d4e6","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#a8d4e6"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#e8e4e0","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0c080","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#e8e4e0","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0dcd8","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#d8d4d0","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0b860","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#f8f5f0","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d898","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#f5d080","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#e8e4e0","fill-outline-color":"#d8d4d0"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#e8e4e0","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#c0b0d0","line-dasharray":[1,1],"line-width":["interpolate",["linear"],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#a090c0","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#a090c0","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#6e6e73","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}}]} \ No newline at end of file +{"version":8,"name":"Apple Light","sources":{"openmaptiles":{"type":"vector","url":"https://tiles.openfreemap.org/planet"}},"sprite":"https://tiles.openfreemap.org/sprites/ofm_f384/ofm","glyphs":"https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf","layers":[{"id":"background","type":"background","paint":{"background-color":"#f8f5f0"}},{"id":"park","type":"fill","source":"openmaptiles","source-layer":"park","paint":{"fill-color":"#c8e6a0","fill-opacity":0.6}},{"id":"landuse_residential","type":"fill","source":"openmaptiles","source-layer":"landuse","maxzoom":12,"filter":["==",["get","class"],"residential"],"paint":{"fill-color":"#f2efe9"}},{"id":"landcover_wood","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"wood"],"paint":{"fill-antialias":false,"fill-color":"#c8dfab","fill-opacity":0.6}},{"id":"landcover_grass","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"grass"],"paint":{"fill-antialias":false,"fill-color":"#d4e8b8","fill-opacity":0.6}},{"id":"landcover_ice","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"ice"],"paint":{"fill-antialias":false,"fill-color":"#e8f0f4","fill-opacity":0.8}},{"id":"landcover_sand","type":"fill","source":"openmaptiles","source-layer":"landcover","filter":["==",["get","class"],"sand"],"paint":{"fill-color":"#f5ebd6"}},{"id":"landuse_pitch","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"pitch"],"paint":{"fill-color":"#b8d88c"}},{"id":"landuse_cemetery","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"cemetery"],"paint":{"fill-color":"#d4e2c8"}},{"id":"landuse_hospital","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"hospital"],"paint":{"fill-color":"#f8e8e8"}},{"id":"landuse_school","type":"fill","source":"openmaptiles","source-layer":"landuse","filter":["==",["get","class"],"school"],"paint":{"fill-color":"#f2ecd8"}},{"id":"waterway_tunnel","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["==",["get","brunnel"],"tunnel"],"paint":{"line-color":"#a8d4e6","line-dasharray":[3,3],"line-width":["interpolate",["exponential",1.4],["zoom"],8,1,20,2]}},{"id":"waterway_river","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["==",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"waterway_other","type":"line","source":"openmaptiles","source-layer":"waterway","filter":["all",["!=",["get","class"],"river"],["!=",["get","brunnel"],"tunnel"]],"layout":{"line-cap":"round"},"paint":{"line-color":"#a8d4e6","line-width":["interpolate",["exponential",1.3],["zoom"],13,0.5,20,6]}},{"id":"water","type":"fill","source":"openmaptiles","source-layer":"water","filter":["!=",["get","brunnel"],"tunnel"],"paint":{"fill-color":"#a8d4e6"}},{"id":"aeroway_fill","type":"fill","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-color":"#e8e4e0","fill-opacity":0.7}},{"id":"aeroway_runway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"runway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,3,20,16]}},{"id":"aeroway_taxiway","type":"line","source":"openmaptiles","source-layer":"aeroway","minzoom":11,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","class"],"taxiway"]],"paint":{"line-color":"#d0ccc8","line-width":["interpolate",["exponential",1.2],["zoom"],11,0.5,20,6]}},{"id":"tunnel_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d0d0d4","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"tunnel_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"tunnel_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,15]}},{"id":"tunnel_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"tunnel_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d0d0d4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d0d0d4","line-dasharray":[0.5,0.25],"line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"tunnel_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"tunnel"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#e8e4e0","line-dasharray":[1,0.75],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"tunnel_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"tunnel_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,11.5]}},{"id":"tunnel_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"tunnel_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"tunnel"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"tunnel_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"tunnel_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"tunnel"],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_area_pattern","type":"fill","source":"openmaptiles","source-layer":"transportation","filter":["match",["geometry-type"],["MultiPolygon","Polygon"],true,false],"paint":{"fill-pattern":"pedestrian_polygon"}},{"id":"road_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"road_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"road_minor_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,20]}},{"id":"road_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"road_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"road_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":14,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["path","pedestrian"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0dcd8","line-dasharray":[1,0.7],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1,20,10]}},{"id":"road_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":12,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["==",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["service","track"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"road_link","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":13,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","ramp"],1],["match",["get","class"],["motorway","path","pedestrian","service","track"],false,true]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"road_minor","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["minor"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"road_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,8,0.5,20,13]}},{"id":"road_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","minzoom":5,"filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"motorway"],["!=",["get","ramp"],1]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#d8d8dc","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"road_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"rail"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_transit_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"road_transit_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["get","brunnel"],["bridge","tunnel"],false,true],["==",["get","class"],"transit"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"road_one_way_arrow","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],1],"layout":{"icon-image":"arrow","symbol-placement":"line"}},{"id":"road_one_way_arrow_opposite","type":"symbol","source":"openmaptiles","source-layer":"transportation","minzoom":16,"filter":["==",["get","oneway"],-1],"layout":{"icon-image":"arrow","icon-rotate":180,"symbol-placement":"line"}},{"id":"bridge_motorway_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_service_track_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],15,1,16,4,20,11]}},{"id":"bridge_link_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],12,1,13,3,14,4,20,15]}},{"id":"bridge_street_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["street","street_limited"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-opacity":["interpolate",["linear"],["zoom"],12,0,12.5,1],"line-width":["interpolate",["exponential",1.2],["zoom"],12,0.5,13,1,14,4,20,25]}},{"id":"bridge_path_pedestrian_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#d8d4d0","line-dasharray":[1,0],"line-width":["interpolate",["exponential",1.2],["zoom"],14,1.5,20,18]}},{"id":"bridge_secondary_tertiary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d4d0","line-width":["interpolate",["exponential",1.2],["zoom"],8,1.5,20,17]}},{"id":"bridge_trunk_primary_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_motorway_casing","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#c0c0c4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0.4,6,0.7,7,1.75,20,22]}},{"id":"bridge_path_pedestrian","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["==",["get","brunnel"],"bridge"],["match",["get","class"],["path","pedestrian"],true,false]],"paint":{"line-color":"#f8f5f0","line-dasharray":[1,0.3],"line-width":["interpolate",["exponential",1.2],["zoom"],14,0.5,20,10]}},{"id":"bridge_motorway_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["==",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_service_track","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["service","track"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],15.5,0,16,2,20,7.5]}},{"id":"bridge_link","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"link"],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],12.5,0,13,1.5,14,2.5,20,11.5]}},{"id":"bridge_street","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["minor"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],13.5,0,14,2.5,20,18]}},{"id":"bridge_secondary_tertiary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["secondary","tertiary"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#ffffff","line-width":["interpolate",["exponential",1.2],["zoom"],6.5,0,7,0.5,20,10]}},{"id":"bridge_trunk_primary","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","brunnel"],"bridge"],["match",["get","class"],["primary","trunk"],true,false]],"layout":{"line-join":"round"},"paint":{"line-color":"#e0e0e4","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_motorway","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"motorway"],["!=",["get","ramp"],1],["==",["get","brunnel"],"bridge"]],"layout":{"line-join":"round"},"paint":{"line-color":"#d8d8dc","line-width":["interpolate",["exponential",1.2],["zoom"],5,0,7,1,20,18]}},{"id":"bridge_major_rail","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-width":["interpolate",["exponential",1.4],["zoom"],14,0.4,15,0.75,20,2]}},{"id":"bridge_major_rail_hatching","type":"line","source":"openmaptiles","source-layer":"transportation","filter":["all",["==",["get","class"],"rail"],["==",["get","brunnel"],"bridge"]],"paint":{"line-color":"#c8c4c0","line-dasharray":[0.2,8],"line-width":["interpolate",["exponential",1.4],["zoom"],14.5,0,15,3,20,8]}},{"id":"building","type":"fill","source":"openmaptiles","source-layer":"building","minzoom":13,"maxzoom":14,"paint":{"fill-color":"#e8e4e0","fill-outline-color":"#d8d4d0"}},{"id":"building-3d","type":"fill-extrusion","source":"openmaptiles","source-layer":"building","minzoom":14,"paint":{"fill-extrusion-base":["get","render_min_height"],"fill-extrusion-color":"#e8e4e0","fill-extrusion-height":["get","render_height"],"fill-extrusion-opacity":0.7}},{"id":"boundary_3","type":"line","source":"openmaptiles","source-layer":"boundary","minzoom":5,"filter":["all",[">=",["get","admin_level"],3],["<=",["get","admin_level"],6],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"paint":{"line-color":"#c0b0d0","line-dasharray":[1,1],"line-width":["interpolate",["linear",1],["zoom"],7,1,11,2]}},{"id":"boundary_2","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["==",["get","admin_level"],2],["!=",["get","maritime"],1],["!=",["get","disputed"],1],["!",["has","claimed_by"]]],"layout":{"line-cap":"round","line-join":"round"},"paint":{"line-color":"#a090c0","line-opacity":["interpolate",["linear"],["zoom"],0,0.4,4,1],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"boundary_disputed","type":"line","source":"openmaptiles","source-layer":"boundary","filter":["all",["!=",["get","maritime"],1],["==",["get","disputed"],1]],"paint":{"line-color":"#a090c0","line-dasharray":[1,2],"line-width":["interpolate",["linear"],["zoom"],3,1,5,1.2,12,3]}},{"id":"waterway_line_label","type":"symbol","source":"openmaptiles","source-layer":"waterway","minzoom":10,"filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_point_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["MultiPoint","Point"],true,false],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":["interpolate",["linear"],["zoom"],0,10,8,14]},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"water_name_line_label","type":"symbol","source":"openmaptiles","source-layer":"water_name","filter":["match",["geometry-type"],["LineString","MultiLineString"],true,false],"layout":{"symbol-placement":"line","symbol-spacing":350,"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":5,"text-size":14},"paint":{"text-color":"#5098c0","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1.5}},{"id":"poi_r20","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":17,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r7","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":16,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],7],["<",["get","rank"],20]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_r1","type":"symbol","source":"openmaptiles","source-layer":"poi","minzoom":15,"filter":["all",["match",["geometry-type"],["MultiPoint","Point"],true,false],[">=",["get","rank"],1],["<",["get","rank"],7]],"layout":{"icon-image":["match",["get","subclass"],["florist","furniture"],["get","subclass"],["get","class"]],"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0,0.6],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"poi_transit","type":"symbol","source":"openmaptiles","source-layer":"poi","filter":["match",["get","class"],["airport","bus","rail"],true,false],"layout":{"icon-image":["to-string",["get","class"]],"icon-size":0.7,"text-anchor":"left","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-max-width":9,"text-offset":[0.9,0],"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"highway-name-path","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15.5,"filter":["==",["get","class"],"path"],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#8e8e93","text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":0.5}},{"id":"highway-name-minor","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":15,"filter":["all",["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","class"],["minor","service","track"],true,false]],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#6e6e73","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-name-major","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":12.2,"filter":["match",["get","class"],["primary","secondary","tertiary","trunk"],true,false],"layout":{"symbol-placement":"line","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"]," ",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"map","text-size":["interpolate",["linear"],["zoom"],13,12,14,13]},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-width":1,"text-halo-color":"rgba(255,255,255,0.8)"}},{"id":"highway-shield-non-us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":8,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-interstate","us-state"],false,true]],"layout":{"icon-image":["concat","road_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"highway-shield-us-interstate","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":7,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-interstate"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",7,"line",8,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"road_shield_us","type":"symbol","source":"openmaptiles","source-layer":"transportation_name","minzoom":9,"filter":["all",["<=",["get","ref_length"],6],["match",["geometry-type"],["LineString","MultiLineString"],true,false],["match",["get","network"],["us-highway","us-state"],true,false]],"layout":{"icon-image":["concat",["get","network"],"_",["get","ref_length"]],"icon-rotation-alignment":"viewport","icon-size":1,"symbol-placement":["step",["zoom"],"point",11,"line"],"symbol-spacing":200,"text-field":["to-string",["get","ref"]],"text-font":["Noto Sans Regular"],"text-rotation-alignment":"viewport","text-size":10}},{"id":"airport","type":"symbol","source":"openmaptiles","source-layer":"aerodrome_label","minzoom":10,"filter":["all",["has","iata"]],"layout":{"icon-image":"airport_11","icon-size":1,"text-anchor":"top","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":9,"text-offset":[0,0.6],"text-optional":true,"text-padding":2,"text-size":12},"paint":{"text-color":"#48484a","text-halo-blur":0.5,"text-halo-color":"rgba(255,255,255,0.8)","text-halo-width":1}},{"id":"label_other","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":8,"filter":["match",["get","class"],["city","continent","country","state","town","village"],false,true],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.1,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],8,9,12,10],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_village","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":9,"filter":["==",["get","class"],"village"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,10,11,12]},"paint":{"text-color":"#6e6e73","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_town","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":6,"filter":["==",["get","class"],"town"],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",10,""],"icon-optional":false,"icon-size":0.2,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-size":["interpolate",["exponential",1.2],["zoom"],7,12,11,14]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_state","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":5,"maxzoom":8,"filter":["==",["get","class"],"state"],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Italic"],"text-letter-spacing":0.2,"text-max-width":9,"text-size":["interpolate",["linear"],["zoom"],5,10,8,14],"text-transform":"uppercase"},"paint":{"text-color":"#8e8e93","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["!=",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.4,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Regular"],"text-max-width":8,"text-offset":[0,-0.1],"text-size":["interpolate",["exponential",1.2],["zoom"],4,11,7,13,11,18]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_city_capital","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":3,"filter":["all",["==",["get","class"],"city"],["==",["get","capital"],2]],"layout":{"icon-allow-overlap":true,"icon-image":["step",["zoom"],"circle_11_black",9,""],"icon-optional":false,"icon-size":0.5,"text-anchor":"bottom","text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":8,"text-offset":[0,-0.2],"text-size":["interpolate",["exponential",1.2],["zoom"],4,12,7,14,11,20]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_3","type":"symbol","source":"openmaptiles","source-layer":"place","minzoom":2,"maxzoom":9,"filter":["all",["==",["get","class"],"country"],[">=",["get","rank"],3]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],3,9,7,17]},"paint":{"text-color":"#48484a","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_2","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],2]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],2,9,5,17]},"paint":{"text-color":"#2c2c2e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}},{"id":"label_country_1","type":"symbol","source":"openmaptiles","source-layer":"place","maxzoom":9,"filter":["all",["==",["get","class"],"country"],["==",["get","rank"],1]],"layout":{"text-field":["case",["has","name:nonlatin"],["concat",["get","name:latin"],"\n",["get","name:nonlatin"]],["coalesce",["get","name_en"],["get","name"]]],"text-font":["Noto Sans Bold"],"text-max-width":6.25,"text-size":["interpolate",["linear"],["zoom"],1,9,4,17]},"paint":{"text-color":"#1c1c1e","text-halo-blur":1,"text-halo-color":"rgba(255,255,255,0.9)","text-halo-width":1}}]} \ No newline at end of file From fc46402a6bd2d8115898f10c42b34c85184f3e59 Mon Sep 17 00:00:00 2001 From: Riley Nielsen Date: Thu, 14 May 2026 22:21:20 -0500 Subject: [PATCH 10/10] feat: introduce typed global IDs for operators, routes, and trips - Added a new `ids` package to handle encoding and decoding of typed global IDs. - Updated the `Processor` to publish messages using operator IDs (e.g., `o-amtrak`) instead of provider names. - Modified the WebSocket handler to support subscriptions based on typed global IDs. - Refactored API routes to utilize typed global IDs for providers, stops, routes, and trips. - Enhanced tests to cover new encoding/decoding logic and ensure proper functionality with the updated ID structure. - Introduced a new `Hub` struct to manage stops and hubs, with a clear distinction in the API responses. --- README.md | 79 ++++---- apps/api/db/schema.sql | 12 +- apps/api/db/static_read.go | 44 ++--- apps/api/gtfs/realtime.go | 25 ++- apps/api/gtfs/static.go | 14 +- apps/api/ids/ids.go | 155 +++++++++++++++ apps/api/ids/ids_test.go | 132 +++++++++++++ apps/api/realtime/process.go | 8 +- apps/api/realtime/process_test.go | 2 +- apps/api/routes/ingest_test.go | 2 +- apps/api/routes/routes.go | 72 ++++--- apps/api/routes/static.go | 304 +++++++++++++++++++++--------- apps/api/spec/static.go | 55 ++++-- apps/api/ws/handler.go | 16 +- apps/api/ws/hub.go | 22 +-- 15 files changed, 700 insertions(+), 242 deletions(-) create mode 100644 apps/api/ids/ids.go create mode 100644 apps/api/ids/ids_test.go diff --git a/README.md b/README.md index c72b76b..a7c847a 100644 --- a/README.md +++ b/README.md @@ -378,43 +378,54 @@ On startup, the app checks for locally cached GTFS data. If the cache is missing - **Real-time cache** — 15s TTL prevents redundant API calls - **Reanimated animations** — all transitions run at 60 fps on the native thread -## API Surface (High Level) +## API Surface Tracky mobile is wired to the backend via: -- REST base URL: `https://api.trackyapp.net` (default from `apps/mobile/constants/config.ts`) -- WebSocket URL: `wss://api.trackyapp.net/ws/realtime` (default from `apps/mobile/constants/config.ts`) - -The endpoint contracts are wrapped in `apps/mobile/services/api-client.ts`, with higher-level train/domain adapters in `apps/mobile/services/api.ts`. - -> Note: `apps/api/cmd/api/main.go` in this workspace currently exposes a minimal local server (`/health`). The `/v1/*` surface below reflects the API expected by the mobile app client. - -### Endpoint Map (REST) - -| Endpoint | Purpose | Maps to app behavior | -| --- | --- | --- | -| `GET /health` | Liveness check for local API process | Local backend smoke check | -| `GET /v1/search?q=&provider=&types=` | Unified search across stations, trains, routes | Search modal suggestions and station-only search flow | -| `GET /v1/providers/{provider}` | Provider metadata (timezone, name, etc.) | Agency/timezone lookups used by cached stop/agency helpers | -| `GET /v1/stops/{provider}/{stopCode}` | Stop metadata by code | Trip detail stop enrichment and station metadata hydration | -| `GET /v1/stops/nearby?lat=&lon=&radius_m=&provider=` | Nearby stations around location | Search screen nearby suggestions (best-effort) | -| `GET /v1/routes?provider=` | List routes for provider | Popular route suggestions in search | -| `GET /v1/routes/{provider}/{routeCode}` | Single route metadata | Route selection hydration and route-name cache prefetch | -| `GET /v1/routes/{provider}/{routeCode}/trains` | Trains that run on a route | Route expansion into train list in search | -| `GET /v1/trains/{trainNumber}/service?provider=&from=&to=` | Service date range for a train number | Bounds the train-number date picker | -| `GET /v1/trips/lookup?provider=&train_number=&date=` | Resolve train number + date into trip IDs | Train-number search flow and train detail resolution | -| `GET /v1/trips/{tripId}` | Trip metadata (route/headsign/service) | `TrainAPIService.getTrainDetails` trip resolution | -| `GET /v1/trips/{tripId}/stops` | Scheduled stop timeline for trip | Train detail timeline, trip selection, saved-trip reconstruction | -| `GET /v1/departures?stop_id=&date=` | Departures/arrivals for a station on a date | Station departure board and nearby suggestion train rows | -| `GET /v1/connections?from_stop=&to_stop=&date=` | Station-to-station trip options | Two-station search results | -| `GET /v1/runs/{provider}/{tripId}/{runDate}/stops` | Per-stop scheduled/estimated/actual realtime rows | Live delay overlays in trip search and trip detail | -| `GET /v1/active?provider=` | Currently active runs snapshot | "Live only" filtering for route train lists | - -### Realtime Stream (WebSocket) - -| Endpoint | Purpose | Maps to app behavior | -| --- | --- | --- | -| `WS /ws/realtime` | Pushes `realtime_update` snapshots by provider after subscribe/unsubscribe messages | Live train markers on map, saved-train realtime refresh, and route-name prefetch warming | +- REST base URL: `https://api.trackyapp.net` (default from [apps/mobile/constants/config.ts](apps/mobile/constants/config.ts)) +- WebSocket URL: `wss://api.trackyapp.net/ws/realtime` + +Endpoints are implemented in [apps/api/routes/static.go](apps/api/routes/static.go) and [routes.go](apps/api/routes/routes.go), and consumed via the typed client in [apps/mobile/services/api-client.ts](apps/mobile/services/api-client.ts) plus the WebSocket client in [apps/mobile/services/ws-client.ts](apps/mobile/services/ws-client.ts). + +Date parameters are `YYYY-MM-DD`. Cacheable read endpoints set `Cache-Control: public, max-age=3600`. All `/ingest` writes require `X-Ingest-Secret`; reads are public (subject to a 30 req/s per-IP rate limit). + +### REST Endpoints + +| Method & Path | Required params | Optional params | Returns | +| --- | --- | --- | --- | +| `GET /health` | — | — | `ok` (text) | +| `GET /v1/search` | `q` (query) | `provider`, `types` (CSV of `stations`, `trains`, `routes`) | `{ stations, trains, routes }` | +| `GET /v1/providers/{provider}` | `provider` (path) | — | Provider metadata | +| `GET /v1/stops` | `provider` (query) | `bbox` (`minLon,minLat,maxLon,maxLat`) | Array of stops | +| `GET /v1/stops/nearby` | `lat`, `lon` (query) | `radius_m` (default 5000, max 50000), `provider` | Array of stops | +| `GET /v1/stops/{provider}/{stopCode}` | `provider`, `stopCode` (path) | — | Stop metadata | +| `GET /v1/routes` | `provider` (query) | — | Array of routes | +| `GET /v1/routes/{provider}/{routeCode}` | `provider`, `routeCode` (path) | — | Route metadata | +| `GET /v1/routes/{provider}/{routeCode}/trains` | `provider`, `routeCode` (path) | — | Array of trains on route | +| `GET /v1/trains/{trainNumber}/service` | `trainNumber` (path), `provider` (query) | `from`, `to` (date) | Service info / date range | +| `GET /v1/trips/lookup` | `provider`, `train_number`, `date` (query) | — | Array of trips | +| `GET /v1/trips/{tripId}` | `tripId` (path) | — | Trip metadata | +| `GET /v1/trips/{tripId}/stops` | `tripId` (path) | — | Scheduled stop timeline | +| `GET /v1/departures` | `stop_id`, `date` (query) | — | Departures/arrivals for the day | +| `GET /v1/connections` | `from_stop`, `to_stop`, `date` (query) | — | Station-to-station trip options | +| `GET /v1/runs/{provider}/{tripId}/{runDate}/stops` | `provider`, `tripId`, `runDate` (path) | — | Per-stop scheduled / estimated / actual times (uncached, realtime) | +| `GET /v1/realtime` | `topic` (query) | — | `{ runs: [...] }` from latest realtime snapshot for the topic | +| `POST /ingest` | `X-Ingest-Secret` header | — | Snapshot ingest from the edge collector | + +### WebSocket + +`WS /ws/realtime` — clients send subscribe/unsubscribe frames and receive `realtime_update` snapshots: + +```jsonc +// Client → server +{ "action": "subscribe", "providers": ["amtrak"] } +{ "action": "unsubscribe", "providers": ["amtrak"] } + +// Server → client +{ "type": "realtime_update", "provider": "amtrak", "positions": [...], "stopTimes": [...] } +``` + +Wire format defined in [apps/api/ws/poller.go](apps/api/ws/poller.go) and [apps/api/ws/handler.go](apps/api/ws/handler.go). ### Where This Is Consumed in the App diff --git a/apps/api/db/schema.sql b/apps/api/db/schema.sql index 188536f..f544a55 100644 --- a/apps/api/db/schema.sql +++ b/apps/api/db/schema.sql @@ -6,9 +6,15 @@ -- covered by these idempotent forms (e.g. adding a column), drop in a real -- migration runner; until then this stays simple. -- --- Identifiers (route_id, stop_id, trip_id) are namespaced upstream as --- ":" so they're globally unique. service_id is --- NOT namespaced; queries must always pair it with provider_id. +-- Identifiers (route_id, stop_id, trip_id) are typed global ids upstream: +-- route_id: r-- e.g. 'r-amtrak-40751' +-- stop_id: s-- e.g. 's-amtrak-CHI' +-- trip_id: t-- e.g. 't-amtrak-251208' +-- '-' is the structural separator. '~' is permitted inside provider or native +-- as a word-break (e.g. 'metra~electric'). See apps/api/ids for the parser. +-- The provider_id column is the bare provider name ('amtrak') — kept as a +-- denormalized facet for indexed filtering and FK cleanliness. service_id is +-- NOT a global id; queries must always pair it with provider_id. CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE EXTENSION IF NOT EXISTS pg_trgm; diff --git a/apps/api/db/static_read.go b/apps/api/db/static_read.go index c004ade..f5eb002 100644 --- a/apps/api/db/static_read.go +++ b/apps/api/db/static_read.go @@ -103,14 +103,12 @@ func (d *DB) GetProvider(ctx context.Context, providerID string) (*spec.Agency, return &a, nil } -// GetStopByCode returns a stop by (provider_id, code). -func (d *DB) GetStopByCode(ctx context.Context, providerID, stopCode string) (*spec.Stop, error) { +// GetStop returns a stop by its typed global id (e.g. 's-amtrak-CHI'). +func (d *DB) GetStop(ctx context.Context, stopID string) (*spec.Stop, error) { row := d.pool.QueryRow(ctx, ` SELECT stop_id, provider_id, code, name, lat, lon, timezone, wheelchair_boarding - FROM stops - WHERE provider_id = $1 AND code = $2 - LIMIT 1`, providerID, stopCode) - var s spec.Stop + FROM stops WHERE stop_id = $1`, stopID) + s := spec.Stop{Type: spec.StopTypeStop} if err := row.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound @@ -120,19 +118,13 @@ func (d *DB) GetStopByCode(ctx context.Context, providerID, stopCode string) (*s return &s, nil } -// GetStopByID returns a stop by its namespaced stop_id. -func (d *DB) GetStopByID(ctx context.Context, stopID string) (*spec.Stop, error) { - row := d.pool.QueryRow(ctx, ` - SELECT stop_id, provider_id, code, name, lat, lon, timezone, wheelchair_boarding - FROM stops WHERE stop_id = $1`, stopID) - var s spec.Stop - if err := row.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, ErrNotFound - } - return nil, err - } - return &s, nil +// GetHub returns a meta-station (hub) by its typed global id (e.g. 'h-amtrak-CHI'). +// +// Stubbed: the hubs table doesn't exist yet, so this always returns ErrNotFound. +// The signature is in place so the polymorphic /v1/stops/{id} handler can wire +// to it once dedup is implemented. +func (d *DB) GetHub(_ context.Context, _ string) (*spec.Hub, error) { + return nil, ErrNotFound } // GetRoute returns a route by its full namespaced route_id. @@ -220,7 +212,7 @@ func (d *DB) ListStopsNearby(ctx context.Context, lat, lon, radiusM float64, pro var out []spec.Stop for rows.Next() { - var s spec.Stop + s := spec.Stop{Type: spec.StopTypeStop} if err := rows.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil { return nil, err } @@ -320,7 +312,7 @@ func (d *DB) ListStops(ctx context.Context, providerID string, bbox BBox) ([]spe var out []spec.Stop for rows.Next() { - var s spec.Stop + s := spec.Stop{Type: spec.StopTypeStop} if err := rows.Scan(&s.StopID, &s.ProviderID, &s.Code, &s.Name, &s.Lat, &s.Lon, &s.Timezone, &s.WheelchairBoarding); err != nil { return nil, err } @@ -511,11 +503,11 @@ func (d *DB) GetConnections(ctx context.Context, fromStopID, toStopID, date stri // Hydrate stop names for from/to and load intermediate stops per trip. out := make([]ConnectionItem, 0, len(pairs)) for _, p := range pairs { - fromStop, err := d.GetStopByID(ctx, fromStopID) + fromStop, err := d.GetStop(ctx, fromStopID) if err != nil && !errors.Is(err, ErrNotFound) { return nil, err } - toStop, err := d.GetStopByID(ctx, toStopID) + toStop, err := d.GetStop(ctx, toStopID) if err != nil && !errors.Is(err, ErrNotFound) { return nil, err } @@ -567,8 +559,10 @@ func (d *DB) intermediateStops(ctx context.Context, tripID string, fromSeq, toSe return out, rows.Err() } -// GetTrainsForRoute returns unique train numbers operating on a route. -func (d *DB) GetTrainsForRoute(ctx context.Context, routeID string) ([]TrainItem, error) { +// GetTripsForRoute returns unique train numbers operating on a route. The name +// reflects the new endpoint (/v1/routes/{r}/trips) — the return shape is still +// the aggregated train-number view that powers the mobile route detail screen. +func (d *DB) GetTripsForRoute(ctx context.Context, routeID string) ([]TrainItem, error) { rows, err := d.pool.Query(ctx, ` SELECT provider_id, short_name, MIN(headsign) AS sample_headsign, COUNT(*) AS trip_count FROM trips diff --git a/apps/api/gtfs/realtime.go b/apps/api/gtfs/realtime.go index 96e2a94..ad522ba 100644 --- a/apps/api/gtfs/realtime.go +++ b/apps/api/gtfs/realtime.go @@ -10,6 +10,7 @@ import ( gtfsrt "github.com/MobilityData/gtfs-realtime-bindings/golang/gtfs" "google.golang.org/protobuf/proto" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/spec" ) @@ -47,8 +48,13 @@ func FetchAndParsePositions( // emitting a position with a zero RunDate. continue } - pos.TripID = providerID + ":" + trip.GetTripId() - pos.RouteID = providerID + ":" + trip.GetRouteId() + if trip.GetTripId() == "" { + continue + } + pos.TripID = ids.MustEncode(ids.KindTrip, providerID, trip.GetTripId()) + if rid := trip.GetRouteId(); rid != "" { + pos.RouteID = ids.MustEncode(ids.KindRoute, providerID, rid) + } pos.RunDate = runDate } @@ -75,8 +81,8 @@ func FetchAndParsePositions( } } - if vp.StopId != nil { - stopID := providerID + ":" + vp.GetStopId() + if sid := vp.GetStopId(); sid != "" { + stopID := ids.MustEncode(ids.KindStop, providerID, sid) pos.CurrentStopCode = &stopID } @@ -129,14 +135,21 @@ func FetchAndParseTripUpdates( // emitting stop times with a zero RunDate. continue } - tripID := providerID + ":" + trip.GetTripId() + if trip.GetTripId() == "" { + continue + } + tripID := ids.MustEncode(ids.KindTrip, providerID, trip.GetTripId()) for _, stu := range tu.StopTimeUpdate { + var stopCode string + if sid := stu.GetStopId(); sid != "" { + stopCode = ids.MustEncode(ids.KindStop, providerID, sid) + } st := spec.TrainStopTime{ Provider: providerID, TripID: tripID, RunDate: runDate, - StopCode: providerID + ":" + stu.GetStopId(), + StopCode: stopCode, LastUpdated: now, } // Only set StopSequence when explicitly provided by the feed; diff --git a/apps/api/gtfs/static.go b/apps/api/gtfs/static.go index 28b9eb6..1f77705 100644 --- a/apps/api/gtfs/static.go +++ b/apps/api/gtfs/static.go @@ -12,6 +12,7 @@ import ( "strconv" "time" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/spec" ) @@ -396,7 +397,7 @@ func parseRoutes(f *zip.File, providerID string) ([]spec.Route, error) { for _, r := range rows { out = append(out, spec.Route{ ProviderID: providerID, - RouteID: providerID + ":" + r["route_id"], + RouteID: ids.MustEncode(ids.KindRoute, providerID, r["route_id"]), ShortName: r["route_short_name"], LongName: r["route_long_name"], Color: r["route_color"], @@ -429,8 +430,9 @@ func parseStops(f *zip.File, providerID string) ([]spec.Stop, error) { code = r["stop_id"] } out = append(out, spec.Stop{ + Type: spec.StopTypeStop, ProviderID: providerID, - StopID: providerID + ":" + r["stop_id"], + StopID: ids.MustEncode(ids.KindStop, providerID, r["stop_id"]), Code: code, Name: r["stop_name"], Lat: lat, @@ -451,8 +453,8 @@ func parseTrips(f *zip.File, providerID string) ([]spec.Trip, error) { for _, r := range rows { out = append(out, spec.Trip{ ProviderID: providerID, - TripID: providerID + ":" + r["trip_id"], - RouteID: providerID + ":" + r["route_id"], + TripID: ids.MustEncode(ids.KindTrip, providerID, r["trip_id"]), + RouteID: ids.MustEncode(ids.KindRoute, providerID, r["route_id"]), ServiceID: r["service_id"], ShortName: r["trip_short_name"], Headsign: r["trip_headsign"], @@ -476,8 +478,8 @@ func parseStopTimes(f *zip.File, providerID string) ([]spec.ScheduledStopTime, e } out = append(out, spec.ScheduledStopTime{ ProviderID: providerID, - TripID: providerID + ":" + r["trip_id"], - StopID: providerID + ":" + r["stop_id"], + TripID: ids.MustEncode(ids.KindTrip, providerID, r["trip_id"]), + StopID: ids.MustEncode(ids.KindStop, providerID, r["stop_id"]), StopSequence: seq, ArrivalTime: optStr(r, "arrival_time"), DepartureTime: optStr(r, "departure_time"), diff --git a/apps/api/ids/ids.go b/apps/api/ids/ids.go new file mode 100644 index 0000000..6354a56 --- /dev/null +++ b/apps/api/ids/ids.go @@ -0,0 +1,155 @@ +// Package ids encodes and decodes Tracky's typed global identifiers. +// +// Every addressable resource is referred to by a self-describing ID of the form +// +// [kind]-[provider]-[native] +// +// where kind is a single-character entity tag (s, r, t, h) and native is the +// provider's own GTFS identifier. Operators are a degenerate case with no +// native id: o-[provider]. +// +// The `-` is the structural separator. Within a single segment (provider or +// native), the `~` character is permitted as a word-break — useful for +// multi-word provider names that don't fit a single token. +// +// Examples: +// - s-amtrak-CHI stop +// - r-amtrak-40751 route +// - t-amtrak-251208 trip +// - h-amtrak-NYC hub (meta-station) +// - o-amtrak operator / provider +// - s-metra~electric-FOO multi-word provider +// - t-brightline-service~A~v2 tildes inside the native id +package ids + +import ( + "errors" + "fmt" + "strings" +) + +type Kind string + +const ( + KindStop Kind = "s" + KindRoute Kind = "r" + KindTrip Kind = "t" + KindHub Kind = "h" + KindOperator Kind = "o" +) + +var knownKinds = map[Kind]bool{ + KindStop: true, KindRoute: true, KindTrip: true, KindHub: true, KindOperator: true, +} + +// ID is the decoded form of a typed global identifier. +// Native is empty when Kind == KindOperator. +type ID struct { + Kind Kind + Provider string + Native string +} + +var ( + ErrEmpty = errors.New("ids: empty input") + ErrMissingDash = errors.New("ids: missing '-' separator") + ErrUnknownKind = errors.New("ids: unknown kind") + ErrEmptyProvider = errors.New("ids: empty provider") + ErrEmptyNative = errors.New("ids: empty native id") + ErrOperatorNative = errors.New("ids: operator id must not have a native segment") + ErrProviderDash = errors.New("ids: provider must not contain '-' (use '~' for multi-word providers)") +) + +// Encode builds a global ID from its parts. Returns an error if any part +// violates the format (empty provider, '-' in provider, etc.). +func Encode(kind Kind, provider, native string) (string, error) { + if !knownKinds[kind] { + return "", fmt.Errorf("%w: %q", ErrUnknownKind, kind) + } + if provider == "" { + return "", ErrEmptyProvider + } + if strings.ContainsRune(provider, '-') { + return "", fmt.Errorf("%w: %q", ErrProviderDash, provider) + } + if kind == KindOperator { + if native != "" { + return "", ErrOperatorNative + } + return string(kind) + "-" + provider, nil + } + if native == "" { + return "", ErrEmptyNative + } + return string(kind) + "-" + provider + "-" + native, nil +} + +// MustEncode is Encode that panics on error. For tests and constants only. +func MustEncode(kind Kind, provider, native string) string { + s, err := Encode(kind, provider, native) + if err != nil { + panic(err) + } + return s +} + +// Decode parses a global ID. Operator IDs (o-foo) are accepted with an empty +// Native field; all other kinds require a non-empty native after the second '-'. +func Decode(s string) (ID, error) { + if s == "" { + return ID{}, ErrEmpty + } + // First dash splits kind from the rest. + kindStr, rest, ok := strings.Cut(s, "-") + if !ok || kindStr == "" { + return ID{}, ErrMissingDash + } + kind := Kind(kindStr) + if !knownKinds[kind] { + return ID{}, fmt.Errorf("%w: %q", ErrUnknownKind, kind) + } + if kind == KindOperator { + if strings.ContainsRune(rest, '-') { + return ID{}, ErrOperatorNative + } + if rest == "" { + return ID{}, ErrEmptyProvider + } + return ID{Kind: kind, Provider: rest}, nil + } + // Second dash splits provider from native. Native may itself contain '-' + // (we cut on the first one only) so e.g. native='NY-PENN' is fine. + provider, native, ok := strings.Cut(rest, "-") + if !ok { + return ID{}, ErrMissingDash + } + if provider == "" { + return ID{}, ErrEmptyProvider + } + if native == "" { + return ID{}, ErrEmptyNative + } + return ID{Kind: kind, Provider: provider, Native: native}, nil +} + +// DecodeKind parses s and asserts its kind matches want. Convenience for +// handlers that already know which kind they expect from the route. +func DecodeKind(s string, want Kind) (ID, error) { + id, err := Decode(s) + if err != nil { + return ID{}, err + } + if id.Kind != want { + return ID{}, fmt.Errorf("ids: expected kind %q, got %q", want, id.Kind) + } + return id, nil +} + +// String returns the encoded form of id, or "" if id is invalid. +func (id ID) String() string { + s, err := Encode(id.Kind, id.Provider, id.Native) + if err != nil { + return "" + } + return s +} diff --git a/apps/api/ids/ids_test.go b/apps/api/ids/ids_test.go new file mode 100644 index 0000000..2295150 --- /dev/null +++ b/apps/api/ids/ids_test.go @@ -0,0 +1,132 @@ +package ids + +import ( + "errors" + "testing" +) + +func TestEncode(t *testing.T) { + cases := []struct { + name string + kind Kind + provider string + native string + want string + wantErr error + }{ + {"stop", KindStop, "amtrak", "CHI", "s-amtrak-CHI", nil}, + {"route", KindRoute, "amtrak", "40751", "r-amtrak-40751", nil}, + {"trip", KindTrip, "amtrak", "251208", "t-amtrak-251208", nil}, + {"hub", KindHub, "amtrak", "NYC", "h-amtrak-NYC", nil}, + {"operator", KindOperator, "amtrak", "", "o-amtrak", nil}, + {"multi-word provider with tilde", KindStop, "metra~electric", "FOO", "s-metra~electric-FOO", nil}, + {"native with dash", KindStop, "amtrak", "NY-PENN", "s-amtrak-NY-PENN", nil}, + {"native with tilde", KindTrip, "brightline", "service~A~v2", "t-brightline-service~A~v2", nil}, + + {"unknown kind", Kind("x"), "amtrak", "CHI", "", ErrUnknownKind}, + {"empty provider", KindStop, "", "CHI", "", ErrEmptyProvider}, + {"provider has dash", KindStop, "metra-electric", "FOO", "", ErrProviderDash}, + {"empty native, non-operator", KindStop, "amtrak", "", "", ErrEmptyNative}, + {"operator with native", KindOperator, "amtrak", "X", "", ErrOperatorNative}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := Encode(tc.kind, tc.provider, tc.native) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestDecode(t *testing.T) { + cases := []struct { + in string + kind Kind + provider string + native string + wantErr error + }{ + {"s-amtrak-CHI", KindStop, "amtrak", "CHI", nil}, + {"r-amtrak-40751", KindRoute, "amtrak", "40751", nil}, + {"t-amtrak-251208", KindTrip, "amtrak", "251208", nil}, + {"h-amtrak-NYC", KindHub, "amtrak", "NYC", nil}, + {"o-amtrak", KindOperator, "amtrak", "", nil}, + {"s-metra~electric-FOO", KindStop, "metra~electric", "FOO", nil}, + // Native containing '-' lands entirely after the second '-'. + {"s-amtrak-NY-PENN", KindStop, "amtrak", "NY-PENN", nil}, + {"t-brightline-service~A~v2", KindTrip, "brightline", "service~A~v2", nil}, + + {"", "", "", "", ErrEmpty}, + {"amtrak", "", "", "", ErrMissingDash}, + {"-amtrak-CHI", "", "", "", ErrMissingDash}, + {"x-amtrak-CHI", "", "", "", ErrUnknownKind}, + {"s--CHI", "", "", "", ErrEmptyProvider}, + {"s-amtrak-", "", "", "", ErrEmptyNative}, + {"s-amtrak", "", "", "", ErrMissingDash}, + {"o-amtrak-X", "", "", "", ErrOperatorNative}, + {"o-", "", "", "", ErrEmptyProvider}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + got, err := Decode(tc.in) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("err = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tc.kind || got.Provider != tc.provider || got.Native != tc.native { + t.Fatalf("got %+v, want {%s %s %s}", got, tc.kind, tc.provider, tc.native) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + cases := []string{ + "s-amtrak-CHI", + "r-amtrak-40751", + "t-amtrak-251208", + "h-amtrak-NYC", + "o-amtrak", + "s-metra~electric-123", + "t-brightline-service~A~v2", + "s-amtrak-NY-PENN", + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + id, err := Decode(in) + if err != nil { + t.Fatalf("decode: %v", err) + } + if id.String() != in { + t.Fatalf("round-trip: got %q, want %q", id.String(), in) + } + }) + } +} + +func TestDecodeKind(t *testing.T) { + if _, err := DecodeKind("s-amtrak-CHI", KindStop); err != nil { + t.Fatalf("expected match: %v", err) + } + if _, err := DecodeKind("s-amtrak-CHI", KindRoute); err == nil { + t.Fatal("expected kind mismatch error") + } + if _, err := DecodeKind("garbage", KindStop); err == nil { + t.Fatal("expected decode error") + } +} diff --git a/apps/api/realtime/process.go b/apps/api/realtime/process.go index 9fa648d..aac0958 100644 --- a/apps/api/realtime/process.go +++ b/apps/api/realtime/process.go @@ -11,6 +11,7 @@ import ( "github.com/RailForLess/tracky/api/collector" "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/ws" ) @@ -31,8 +32,9 @@ func (p *Processor) Process(ctx context.Context, snap *collector.Snapshot) error return fmt.Errorf("realtime: nil snapshot or feed") } - // Wire format matches the existing ws.RealtimeUpdate so iOS clients - // see no change vs. the legacy in-process poller. + // Topic is the operator's typed global id (o-) so that future + // versions can also publish to route/trip/vehicle topics without renaming. + topic := ids.MustEncode(ids.KindOperator, snap.ProviderID, "") payload, err := json.Marshal(ws.RealtimeUpdate{ Type: "realtime_update", Provider: snap.ProviderID, @@ -41,7 +43,7 @@ func (p *Processor) Process(ctx context.Context, snap *collector.Snapshot) error if err != nil { return fmt.Errorf("realtime: marshal: %w", err) } - p.Hub.Publish(snap.ProviderID, payload) + p.Hub.Publish(topic, payload) if p.DB != nil && len(snap.Feed.StopTimes) > 0 { if err := p.DB.UpsertTrainStopTimes(ctx, snap.Feed.StopTimes); err != nil { diff --git a/apps/api/realtime/process_test.go b/apps/api/realtime/process_test.go index 0657c21..f31ba96 100644 --- a/apps/api/realtime/process_test.go +++ b/apps/api/realtime/process_test.go @@ -33,7 +33,7 @@ func TestProcessor_PublishesToHubInLegacyShape(t *testing.T) { // Snapshot is async — wait briefly for hub Run loop to land it. deadline := time.After(time.Second) for { - if payload, ok := hub.Snapshot("amtrak"); ok { + if payload, ok := hub.Snapshot("o-amtrak"); ok { var u ws.RealtimeUpdate if err := json.Unmarshal(payload, &u); err != nil { t.Fatalf("unmarshal: %v", err) diff --git a/apps/api/routes/ingest_test.go b/apps/api/routes/ingest_test.go index ce67e8a..2e1788c 100644 --- a/apps/api/routes/ingest_test.go +++ b/apps/api/routes/ingest_test.go @@ -57,7 +57,7 @@ func TestIngest_Accepts204(t *testing.T) { } // Hub publish is async — wait briefly. for range 50 { - if _, ok := hub.Snapshot("amtrak"); ok { + if _, ok := hub.Snapshot("o-amtrak"); ok { return } time.Sleep(10 * time.Millisecond) diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 21f3108..30bac87 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/ids" "github.com/RailForLess/tracky/api/realtime" "github.com/RailForLess/tracky/api/ws" ) @@ -13,8 +14,11 @@ import ( // /v1/* read endpoints are not registered. func Setup(mux *http.ServeMux, hub *ws.Hub, processor *realtime.Processor, database *db.DB, ingestSecret string) { mux.HandleFunc("POST /ingest", HandleIngest(processor, ingestSecret)) - mux.HandleFunc("GET /debug/providers/{id}/realtime", handleSyncRealtime(hub)) - mux.HandleFunc("GET /v1/active", handleActiveTrains(hub)) + + // Currently-tracked runs, sourced from the hub snapshot. Future history + // (past-day runs) will live at `/v1/trips/{trip_id}/runs?from=&to=` backed + // by Timescale — this endpoint stays scoped to "live now". + mux.HandleFunc("GET /v1/realtime", handleRealtimeRuns(hub)) if database != nil { registerStatic(mux, database) @@ -27,53 +31,41 @@ func writeJSON(w http.ResponseWriter, status int, v any) { json.NewEncoder(w).Encode(v) } -// handleSyncRealtime returns the cached realtime snapshot for a provider. -func handleSyncRealtime(hub *ws.Hub) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - snapshot, ok := hub.Snapshot(id) - if !ok { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "no realtime data yet for provider " + id, - }) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(snapshot) - } -} - -// ActiveTrain identifies a single in-progress run, derived from the latest -// hub snapshot. Used by clients (e.g. the "live only" filter in the mobile -// trip search) to know which runs are currently being tracked without -// needing a full WebSocket subscription. -type ActiveTrain struct { - Provider string `json:"provider"` - TripID string `json:"tripId"` +// Run identifies a single in-progress run, derived from the latest hub +// snapshot. A run is a trip × run_date instance — distinct from the scheduled +// trip template returned by /v1/trips. +type Run struct { + ProviderID string `json:"providerId"` // bare provider for ergonomics + TripID string `json:"tripId"` // t-amtrak-... RunDate string `json:"runDate"` TrainNumber string `json:"trainNumber"` - RouteID string `json:"routeId"` + RouteID string `json:"routeId"` // r-amtrak-... } -// handleActiveTrains returns the set of currently-tracked runs for a provider, -// sourced from the most-recent realtime snapshot the hub has published. +// handleRealtimeRuns serves GET /v1/realtime?topic= — currently-tracked runs +// from the hub's most-recent snapshot for the given topic. // -// GET /v1/active?provider=amtrak → { activeTrains: [...] } -func handleActiveTrains(hub *ws.Hub) http.HandlerFunc { +// The topic param accepts any well-formed global id (operator, route, trip, +// etc.), mirroring the WebSocket subscribe protocol. Today only operator +// topics ('o-') are published by the realtime processor; route/trip +// topics return empty until finer-grained fan-out lands. +func handleRealtimeRuns(hub *ws.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.URL.Query().Get("provider") - if provider == "" { - writeError(w, http.StatusBadRequest, "provider query param required") + topic := r.URL.Query().Get("topic") + if topic == "" { + writeError(w, http.StatusBadRequest, "topic query param required (typed global id)") + return + } + if _, err := ids.Decode(topic); err != nil { + writeError(w, http.StatusBadRequest, "topic must be a well-formed global id") return } out := struct { - ActiveTrains []ActiveTrain `json:"activeTrains"` - }{ActiveTrains: []ActiveTrain{}} + Runs []Run `json:"runs"` + }{Runs: []Run{}} - snapshot, ok := hub.Snapshot(provider) + snapshot, ok := hub.Snapshot(topic) if !ok { // No realtime data yet — return empty list (not an error). writeJSON(w, http.StatusOK, out) @@ -87,8 +79,8 @@ func handleActiveTrains(hub *ws.Hub) http.HandlerFunc { } for _, p := range update.Positions { - out.ActiveTrains = append(out.ActiveTrains, ActiveTrain{ - Provider: p.Provider, + out.Runs = append(out.Runs, Run{ + ProviderID: p.Provider, TripID: p.TripID, RunDate: p.RunDate.Format("2006-01-02"), TrainNumber: p.TrainNumber, diff --git a/apps/api/routes/static.go b/apps/api/routes/static.go index 9f38063..39653e0 100644 --- a/apps/api/routes/static.go +++ b/apps/api/routes/static.go @@ -9,31 +9,37 @@ import ( "time" "github.com/RailForLess/tracky/api/db" + "github.com/RailForLess/tracky/api/ids" ) // registerStatic wires every read endpoint exposed under /v1/. +// +// Resources are addressed by typed global ids — see apps/api/ids/. The provider +// segment is no longer in the URL path; it lives inside the id. func registerStatic(mux *http.ServeMux, d *db.DB) { - mux.HandleFunc("GET /v1/providers/{provider}", handleGetProvider(d)) + mux.HandleFunc("GET /v1/providers/{providerID}", handleGetProvider(d)) - mux.HandleFunc("GET /v1/stops/nearby", handleNearbyStops(d)) - mux.HandleFunc("GET /v1/stops/{provider}/{stopCode}", handleGetStop(d)) + // Polymorphic: 's-' returns a Stop, 'h-' (when implemented) returns a Hub. + mux.HandleFunc("GET /v1/stops/{stopID}", handleGetStop(d)) + // Spatial / list. Accepts either ?bbox=... or ?lat=&lon=&radius_m=. mux.HandleFunc("GET /v1/stops", handleListStops(d)) + mux.HandleFunc("GET /v1/stops/{stopID}/departures", handleDepartures(d)) - mux.HandleFunc("GET /v1/routes/{provider}/{routeCode}", handleGetRoute(d)) + mux.HandleFunc("GET /v1/routes/{routeID}", handleGetRoute(d)) mux.HandleFunc("GET /v1/routes", handleListRoutes(d)) - mux.HandleFunc("GET /v1/routes/{provider}/{routeCode}/trains", handleTrainsForRoute(d)) + mux.HandleFunc("GET /v1/routes/{routeID}/trips", handleTripsForRoute(d)) - mux.HandleFunc("GET /v1/trips/lookup", handleLookupTrips(d)) - mux.HandleFunc("GET /v1/trips/{tripId}/stops", handleTripStops(d)) - mux.HandleFunc("GET /v1/trips/{tripId}", handleGetTrip(d)) + // Scheduled-trip lookup by train number on a service date. Realtime "what + // is currently running" lives at /v1/realtime (wired in routes.go). + mux.HandleFunc("GET /v1/trips", handleListTripsByLookup(d)) + mux.HandleFunc("GET /v1/trips/service", handleTrainService(d)) + mux.HandleFunc("GET /v1/trips/{tripID}/stops", handleTripStops(d)) + mux.HandleFunc("GET /v1/trips/{tripID}", handleGetTrip(d)) - mux.HandleFunc("GET /v1/runs/{provider}/{tripId}/{runDate}/stops", handleRunStops(d)) + mux.HandleFunc("GET /v1/trips/{tripID}/runs/{runDate}/stops", handleRunStops(d)) - mux.HandleFunc("GET /v1/departures", handleDepartures(d)) mux.HandleFunc("GET /v1/connections", handleConnections(d)) - mux.HandleFunc("GET /v1/trains/{trainNumber}/service", handleTrainService(d)) - mux.HandleFunc("GET /v1/search", handleSearch(d)) } @@ -103,12 +109,46 @@ func parseBBox(s string) (db.BBox, bool) { }, true } +// decodePath parses a path-segment global id and writes a 400 if malformed +// or 400 if the kind doesn't match `want`. Returns (parsed, false) on error. +func decodePath(w http.ResponseWriter, raw string, want ids.Kind, label string) (ids.ID, bool) { + id, err := ids.Decode(raw) + if err != nil { + writeError(w, http.StatusBadRequest, label+": invalid id format") + return ids.ID{}, false + } + if id.Kind != want { + writeError(w, http.StatusBadRequest, label+": expected "+string(want)+"- prefix, got "+string(id.Kind)+"-") + return ids.ID{}, false + } + return id, true +} + +// providerFromQuery returns the bare provider id from a query param holding a +// typed operator id ('o-amtrak'). Empty input yields ("", true) — callers +// that require the filter must reject it themselves. Any non-empty value that +// isn't a well-formed operator id is rejected. +func providerFromQuery(raw string) (string, bool) { + if raw == "" { + return "", true + } + id, err := ids.Decode(raw) + if err != nil || id.Kind != ids.KindOperator { + return "", false + } + return id.Provider, true +} + // ── Handlers ──────────────────────────────────────────────────────────── func handleGetProvider(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - a, err := d.GetProvider(r.Context(), provider) + raw := r.PathValue("providerID") + id, ok := decodePath(w, raw, ids.KindOperator, "provider id") + if !ok { + return + } + a, err := d.GetProvider(r.Context(), id.Provider) if errors.Is(err, db.ErrNotFound) { notFound(w, "provider not found") return @@ -122,33 +162,99 @@ func handleGetProvider(d *db.DB) http.HandlerFunc { } } +// handleGetStop is polymorphic: returns a Stop for 's-' ids and a Hub for 'h-'. +// The JSON response carries a `type` discriminator so clients can use a +// discriminated union. func handleGetStop(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - code := r.PathValue("stopCode") - s, err := d.GetStopByCode(r.Context(), provider, code) - if errors.Is(err, db.ErrNotFound) { - notFound(w, "stop not found") - return - } + raw := r.PathValue("stopID") + id, err := ids.Decode(raw) if err != nil { - serverError(w, err) + writeError(w, http.StatusBadRequest, "stop id: invalid id format") return } - setCacheable(w) - writeJSON(w, http.StatusOK, s) + switch id.Kind { + case ids.KindStop: + s, err := d.GetStop(r.Context(), raw) + if errors.Is(err, db.ErrNotFound) { + notFound(w, "stop not found") + return + } + if err != nil { + serverError(w, err) + return + } + setCacheable(w) + writeJSON(w, http.StatusOK, s) + case ids.KindHub: + h, err := d.GetHub(r.Context(), raw) + if errors.Is(err, db.ErrNotFound) { + // Until hubs are ingested this is the steady-state response. + writeError(w, http.StatusNotImplemented, "hubs are not yet supported") + return + } + if err != nil { + serverError(w, err) + return + } + setCacheable(w) + writeJSON(w, http.StatusOK, h) + default: + writeError(w, http.StatusBadRequest, "stop id: expected s- or h- prefix, got "+string(id.Kind)+"-") + } } } +// handleListStops serves both the bbox query (?bbox=) and the nearby query +// (?lat=&lon=&radius_m=). At least one shape is required. func handleListStops(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.URL.Query().Get("provider") + q := r.URL.Query() + provider, ok := providerFromQuery(q.Get("provider_id")) + if !ok { + writeError(w, http.StatusBadRequest, "provider_id must be an o- prefixed operator id") + return + } + + // Nearby: lat/lon required, radius optional. + latRaw := q.Get("lat") + lonRaw := q.Get("lon") + if latRaw != "" || lonRaw != "" { + lat, latOK := parseFloat(latRaw) + lon, lonOK := parseFloat(lonRaw) + if !latOK || !lonOK || lat < -90 || lat > 90 || lon < -180 || lon > 180 { + writeError(w, http.StatusBadRequest, "lat and lon are required (lat in [-90,90], lon in [-180,180])") + return + } + radius := 5000.0 + if raw := q.Get("radius_m"); raw != "" { + v, ok := parseFloat(raw) + if !ok || v <= 0 { + writeError(w, http.StatusBadRequest, "radius_m must be a positive number") + return + } + radius = v + } + if radius > 50000 { + radius = 50000 + } + stops, err := d.ListStopsNearby(r.Context(), lat, lon, radius, provider) + if err != nil { + serverError(w, err) + return + } + setCacheable(w) + writeJSON(w, http.StatusOK, stops) + return + } + + // Bbox / list mode: provider required. if provider == "" { - writeError(w, http.StatusBadRequest, "provider query param required") + writeError(w, http.StatusBadRequest, "provider_id required when not using lat/lon") return } - bbox, ok := parseBBox(r.URL.Query().Get("bbox")) - if !ok { + bbox, bboxOK := parseBBox(q.Get("bbox")) + if !bboxOK { writeError(w, http.StatusBadRequest, "bbox must be minLon,minLat,maxLon,maxLat") return } @@ -164,9 +270,11 @@ func handleListStops(d *db.DB) http.HandlerFunc { func handleGetRoute(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - code := r.PathValue("routeCode") - route, err := d.GetRoute(r.Context(), provider+":"+code) + raw := r.PathValue("routeID") + if _, ok := decodePath(w, raw, ids.KindRoute, "route id"); !ok { + return + } + route, err := d.GetRoute(r.Context(), raw) if errors.Is(err, db.ErrNotFound) { notFound(w, "route not found") return @@ -182,9 +290,13 @@ func handleGetRoute(d *db.DB) http.HandlerFunc { func handleListRoutes(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.URL.Query().Get("provider") + provider, ok := providerFromQuery(r.URL.Query().Get("provider_id")) + if !ok { + writeError(w, http.StatusBadRequest, "provider_id must be an o- prefixed operator id") + return + } if provider == "" { - writeError(w, http.StatusBadRequest, "provider query param required") + writeError(w, http.StatusBadRequest, "provider_id query param required") return } routes, err := d.ListRoutes(r.Context(), provider) @@ -197,24 +309,29 @@ func handleListRoutes(d *db.DB) http.HandlerFunc { } } -func handleTrainsForRoute(d *db.DB) http.HandlerFunc { +func handleTripsForRoute(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - code := r.PathValue("routeCode") - trains, err := d.GetTrainsForRoute(r.Context(), provider+":"+code) + raw := r.PathValue("routeID") + if _, ok := decodePath(w, raw, ids.KindRoute, "route id"); !ok { + return + } + trips, err := d.GetTripsForRoute(r.Context(), raw) if err != nil { serverError(w, err) return } setCacheable(w) - writeJSON(w, http.StatusOK, trains) + writeJSON(w, http.StatusOK, trips) } } func handleGetTrip(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - tripID := r.PathValue("tripId") - trip, err := d.GetTrip(r.Context(), tripID) + raw := r.PathValue("tripID") + if _, ok := decodePath(w, raw, ids.KindTrip, "trip id"); !ok { + return + } + trip, err := d.GetTrip(r.Context(), raw) if errors.Is(err, db.ErrNotFound) { notFound(w, "trip not found") return @@ -230,8 +347,11 @@ func handleGetTrip(d *db.DB) http.HandlerFunc { func handleTripStops(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - tripID := r.PathValue("tripId") - stops, err := d.GetTripStops(r.Context(), tripID) + raw := r.PathValue("tripID") + if _, ok := decodePath(w, raw, ids.KindTrip, "trip id"); !ok { + return + } + stops, err := d.GetTripStops(r.Context(), raw) if err != nil { serverError(w, err) return @@ -241,14 +361,16 @@ func handleTripStops(d *db.DB) http.HandlerFunc { } } -func handleLookupTrips(d *db.DB) http.HandlerFunc { +// handleListTripsByLookup serves GET /v1/trips?train_number=&date= — scheduled +// trips for a service date. Currently-running trips are at /v1/realtime. +func handleListTripsByLookup(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - provider := q.Get("provider") + provider, providerOK := providerFromQuery(q.Get("provider_id")) train := q.Get("train_number") date, ok := parseDate(q.Get("date")) - if provider == "" || train == "" || !ok { - writeError(w, http.StatusBadRequest, "provider, train_number, and date (YYYY-MM-DD) required") + if !providerOK || provider == "" || train == "" || !ok { + writeError(w, http.StatusBadRequest, "provider_id, train_number, and date (YYYY-MM-DD) required") return } trips, err := d.LookupTripsByTrainNumber(r.Context(), provider, train, date) @@ -263,14 +385,26 @@ func handleLookupTrips(d *db.DB) http.HandlerFunc { func handleDepartures(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - stopID := q.Get("stop_id") - date, ok := parseDate(q.Get("date")) - if stopID == "" || !ok { - writeError(w, http.StatusBadRequest, "stop_id and date (YYYY-MM-DD) required") + raw := r.PathValue("stopID") + id, err := ids.Decode(raw) + if err != nil { + writeError(w, http.StatusBadRequest, "stop id: invalid id format") + return + } + if id.Kind != ids.KindStop && id.Kind != ids.KindHub { + writeError(w, http.StatusBadRequest, "stop id: expected s- or h- prefix") + return + } + if id.Kind == ids.KindHub { + writeError(w, http.StatusNotImplemented, "hub departures are not yet supported") + return + } + date, ok := parseDate(r.URL.Query().Get("date")) + if !ok { + writeError(w, http.StatusBadRequest, "date (YYYY-MM-DD) required") return } - departures, err := d.GetDepartures(r.Context(), stopID, date) + departures, err := d.GetDepartures(r.Context(), raw, date) if err != nil { serverError(w, err) return @@ -290,6 +424,14 @@ func handleConnections(d *db.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "from_stop, to_stop, and date (YYYY-MM-DD) required") return } + if fid, err := ids.Decode(from); err != nil || fid.Kind != ids.KindStop { + writeError(w, http.StatusBadRequest, "from_stop must be an s- prefixed stop id") + return + } + if tid, err := ids.Decode(to); err != nil || tid.Kind != ids.KindStop { + writeError(w, http.StatusBadRequest, "to_stop must be an s- prefixed stop id") + return + } conns, err := d.GetConnections(r.Context(), from, to, date) if err != nil { serverError(w, err) @@ -302,13 +444,13 @@ func handleConnections(d *db.DB) http.HandlerFunc { func handleTrainService(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - train := r.PathValue("trainNumber") q := r.URL.Query() - provider := q.Get("provider") + provider, providerOK := providerFromQuery(q.Get("provider_id")) + train := q.Get("train_number") from := q.Get("from") to := q.Get("to") - if provider == "" || train == "" { - writeError(w, http.StatusBadRequest, "provider query param and trainNumber required") + if !providerOK || provider == "" || train == "" { + writeError(w, http.StatusBadRequest, "provider_id and train_number required") return } if from != "" { @@ -339,14 +481,17 @@ func handleTrainService(d *db.DB) http.HandlerFunc { func handleRunStops(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - tripID := r.PathValue("tripId") + raw := r.PathValue("tripID") + id, ok := decodePath(w, raw, ids.KindTrip, "trip id") + if !ok { + return + } runDate, ok := parseDate(r.PathValue("runDate")) - if provider == "" || tripID == "" || !ok { - writeError(w, http.StatusBadRequest, "provider, tripId, and runDate (YYYY-MM-DD) required") + if !ok { + writeError(w, http.StatusBadRequest, "runDate must be YYYY-MM-DD") return } - stops, err := d.GetRunStops(r.Context(), provider, tripID, runDate) + stops, err := d.GetRunStops(r.Context(), id.Provider, raw, runDate) if errors.Is(err, db.ErrNotFound) { notFound(w, "run not found") return @@ -360,37 +505,6 @@ func handleRunStops(d *db.DB) http.HandlerFunc { } } -func handleNearbyStops(d *db.DB) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - lat, latOK := parseFloat(q.Get("lat")) - lon, lonOK := parseFloat(q.Get("lon")) - if !latOK || !lonOK || lat < -90 || lat > 90 || lon < -180 || lon > 180 { - writeError(w, http.StatusBadRequest, "lat and lon are required (lat in [-90,90], lon in [-180,180])") - return - } - radius := 5000.0 - if raw := q.Get("radius_m"); raw != "" { - v, ok := parseFloat(raw) - if !ok || v <= 0 { - writeError(w, http.StatusBadRequest, "radius_m must be a positive number") - return - } - radius = v - } - if radius > 50000 { - radius = 50000 - } - stops, err := d.ListStopsNearby(r.Context(), lat, lon, radius, q.Get("provider")) - if err != nil { - serverError(w, err) - return - } - setCacheable(w) - writeJSON(w, http.StatusOK, stops) - } -} - func handleSearch(d *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -399,7 +513,11 @@ func handleSearch(d *db.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "q is required") return } - provider := q.Get("provider") + provider, ok := providerFromQuery(q.Get("provider_id")) + if !ok { + writeError(w, http.StatusBadRequest, "provider_id must be an o- prefixed operator id") + return + } types := q.Get("types") incStations, incTrains, incRoutes := true, true, true if types != "" { diff --git a/apps/api/spec/static.go b/apps/api/spec/static.go index e85e60c..9111014 100644 --- a/apps/api/spec/static.go +++ b/apps/api/spec/static.go @@ -20,7 +20,7 @@ type Agency struct { // Maps to GTFS routes.txt. type Route struct { ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' - RouteID string `db:"route_id" json:"routeId"` // namespaced: 'amtrak:coast-starlight' + RouteID string `db:"route_id" json:"routeId"` // typed global id: 'r-amtrak-coast-starlight' ShortName string `db:"short_name" json:"shortName"` // '14' LongName string `db:"long_name" json:"longName"` // 'Coast Starlight' Color string `db:"color" json:"color"` // hex without #, e.g. '1D2E6E' @@ -28,17 +28,31 @@ type Route struct { ShapeID *string `db:"shape_id" json:"shapeId"` // reference into tile layer, not a DB table } +// StopType is the JSON discriminator emitted on /v1/stops/{id} responses so +// TypeScript clients can use a discriminated union over Stop | Hub. +type StopType string + +const ( + StopTypeStop StopType = "stop" + StopTypeHub StopType = "hub" +) + // Stop represents a physical station or stop. // Maps to GTFS stops.txt. +// +// Type is the polymorphic discriminator for /v1/stops/{id} responses; it is +// always StopTypeStop on this struct. The Hub variant of the union lives in +// spec.Hub and emits StopTypeHub. type Stop struct { - ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' - StopID string `db:"stop_id" json:"stopId"` // namespaced: 'amtrak:LAX' - Code string `db:"code" json:"code"` // native code: 'LAX' - Name string `db:"name" json:"name"` // 'Los Angeles' - Lat float64 `db:"lat" json:"lat"` - Lon float64 `db:"lon" json:"lon"` - Timezone *string `db:"timezone" json:"timezone"` // stop-local tz if different from agency - WheelchairBoarding *bool `db:"wheelchair_boarding" json:"wheelchairBoarding"` + Type StopType `db:"-" json:"type"` // always "stop" + ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' + StopID string `db:"stop_id" json:"stopId"` // typed global id: 's-amtrak-LAX' + Code string `db:"code" json:"code"` // native code: 'LAX' + Name string `db:"name" json:"name"` // 'Los Angeles' + Lat float64 `db:"lat" json:"lat"` + Lon float64 `db:"lon" json:"lon"` + Timezone *string `db:"timezone" json:"timezone"` // stop-local tz if different from agency + WheelchairBoarding *bool `db:"wheelchair_boarding" json:"wheelchairBoarding"` } // Trip represents a scheduled service pattern. @@ -46,8 +60,8 @@ type Stop struct { // Note: a Trip is the template; a run is Trip + RunDate. type Trip struct { ProviderID string `db:"provider_id" json:"providerId"` // 'amtrak' - TripID string `db:"trip_id" json:"tripId"` // namespaced: 'amtrak:5' - RouteID string `db:"route_id" json:"routeId"` // 'amtrak:coast-starlight' + TripID string `db:"trip_id" json:"tripId"` // typed global id: 't-amtrak-5' + RouteID string `db:"route_id" json:"routeId"` // 'r-amtrak-coast-starlight' ServiceID string `db:"service_id" json:"serviceId"` // links to ServiceCalendar ShortName string `db:"short_name" json:"shortName"` // GTFS trip_short_name — train number, e.g. '5' Headsign string `db:"headsign" json:"headsign"` // 'Chicago' @@ -55,6 +69,25 @@ type Trip struct { DirectionID *int `db:"direction_id" json:"directionId"` // 0=outbound, 1=inbound } +// Hub is the future "meta-station" type — a deduplicated grouping of stops +// across providers at a single physical location (e.g. CHI Union Station +// served by both Amtrak and Metra). Polymorphic /v1/stops/{id} returns this +// for ids with the 'h-' prefix. +// +// Stubbed: hubs are not yet ingested, so handlers return 501 for hub ids. +// The struct lives here so the JSON contract is stable when the table lands. +type Hub struct { + Type StopType `db:"-" json:"type"` // always "hub" + HubID string `db:"hub_id" json:"hubId"` // typed global id: 'h-amtrak-CHI~UNION' + Name string `db:"name" json:"name"` // 'Chicago Union Station' + Lat float64 `db:"lat" json:"lat"` + Lon float64 `db:"lon" json:"lon"` + Timezone *string `db:"timezone" json:"timezone"` + // Members are the stop ids that belong to this hub. Populated by the + // hub-resolution layer at read time. + Members []string `db:"-" json:"members"` +} + // ScheduledStopTime represents a trip's scheduled arrival/departure at a stop. // Maps to GTFS stop_times.txt. Static timetable only — never updated. // Actual and estimated times live in TrainStopTime (realtime model). diff --git a/apps/api/ws/handler.go b/apps/api/ws/handler.go index 5b4f15f..a6b777d 100644 --- a/apps/api/ws/handler.go +++ b/apps/api/ws/handler.go @@ -25,8 +25,8 @@ var upgrader = websocket.Upgrader{ } type clientMsg struct { - Action string `json:"action"` // "subscribe" | "unsubscribe" - Providers []string `json:"providers"` // e.g. ["cta", "amtrak"] + Action string `json:"action"` // "subscribe" | "unsubscribe" + Topics []string `json:"topics"` // typed global ids, e.g. ["o-amtrak", "o-cta"] } // Handler returns an http.HandlerFunc that upgrades connections to WebSocket. @@ -107,9 +107,9 @@ func readPump(hub *Hub, c *Client) { // Additive: add to the existing subscription set instead of replacing it. // Send cached snapshots only for newly added topics so a client that // re-subscribes to something it already had doesn't get a duplicate. - added := c.addTopics(msg.Providers) - for _, p := range added { - if snapshot, ok := hub.Snapshot(p); ok { + added := c.addTopics(msg.Topics) + for _, t := range added { + if snapshot, ok := hub.Snapshot(t); ok { // Route through the hub goroutine so c.send is only // written by the hub, avoiding a race with close on // unregister/backpressure. @@ -117,13 +117,13 @@ func readPump(hub *Hub, c *Client) { } } case "unsubscribe": - // Targeted: remove only the listed providers. An empty/missing list + // Targeted: remove only the listed topics. An empty/missing list // clears all subscriptions (preserves the original "unsubscribe = stop // everything" shortcut). - if len(msg.Providers) == 0 { + if len(msg.Topics) == 0 { c.clearTopics() } else { - c.removeTopics(msg.Providers) + c.removeTopics(msg.Topics) } } } diff --git a/apps/api/ws/hub.go b/apps/api/ws/hub.go index dd3cf5d..0488d39 100644 --- a/apps/api/ws/hub.go +++ b/apps/api/ws/hub.go @@ -22,29 +22,29 @@ func (c *Client) subscribedTo(topic string) bool { return ok } -// addTopics unions providers into the client's subscription set and returns +// addTopics unions topics into the client's subscription set and returns // the topics that were not already subscribed (caller uses this to send the // cached snapshot only for newly added topics). -func (c *Client) addTopics(providers []string) []string { +func (c *Client) addTopics(topics []string) []string { c.mu.Lock() defer c.mu.Unlock() var added []string - for _, p := range providers { - if _, ok := c.topics[p]; !ok { - c.topics[p] = struct{}{} - added = append(added, p) + for _, t := range topics { + if _, ok := c.topics[t]; !ok { + c.topics[t] = struct{}{} + added = append(added, t) } } return added } -// removeTopics drops the given providers from the subscription set. Topics +// removeTopics drops the given topics from the subscription set. Topics // that aren't currently subscribed are silently ignored. -func (c *Client) removeTopics(providers []string) { +func (c *Client) removeTopics(topics []string) { c.mu.Lock() defer c.mu.Unlock() - for _, p := range providers { - delete(c.topics, p) + for _, t := range topics { + delete(c.topics, t) } } @@ -65,7 +65,7 @@ type clientDelivery struct { payload []byte } -// Hub manages WebSocket clients and routes messages by topic (provider ID). +// Hub manages WebSocket clients and routes messages by topic (typed global id). type Hub struct { mu sync.RWMutex clients map[*Client]struct{}