3D visualization of Macau's public transit, ferry, and aviation system, inspired by Mini Tokyo 3D and Mini Taiwan.
Visualizes the Macau Light Rapid Transit (LRT), bus network, HK–Macau ferry routes, and MFM airport flights on an interactive 3D map. Vehicles move along actual geometry in a timetable-driven simulation. A CITY layer set adds Macau's open data on top: DSAT road-works notices, every school's buildings coloured by level and shaded by founding era, the Housing Bureau's social and economic housing estates, the seven civil parishes as a boundary tint, IAM public toilets, DSAT public car parks with live vacancy, and IAM/DSPA refuse rooms, compacting bins and recycling points.
How fresh is this? See Data freshness & update strategy for a per-layer breakdown — LRT and buses run on simulated, manually regenerated timetables, while flights and ferries refresh on their own daily/monthly sync schedule.
Full-quality MP4s: bus fleet · LRT line
- Features
- Architecture
- Tech Stack
- Getting Started
- Data Pipeline
- Data Sources
- Data freshness & update strategy
- Project Structure
- Performance Notes
- Acknowledgements
- License
- Developer Docs 開發筆記 (繁中)
- 3D LRT vehicles — 3 lines, 15 stations, real track geometry and elevated viaducts
- 3D Bus fleet — 92 routes, road-snapped via OSRM, with accurate cross-harbour bridge geometry
- 3D Aircraft — 176 real MFM flights (87 dep + 89 arr) with detailed airplane models, apron stands, and taxi paths
- 3D Ferries — 6 HK/Shenzhen ↔ Macau sea routes (TurboJET + CotaiJet) with jetfoil-shaped hull, red belly belt, and multi-deck cabin
- Timetable-driven simulation — Schedule-synced playback with ETAs, service status, and trilingual labels (EN / 繁中 / PT)
- Time controls — Play/pause, 1×–60× speed, jump-to-now, free date/time picker
- Vehicle tracking — Click-to-follow with smooth camera and free zoom/pan
Full feature list
- 3D LRT vehicles — All 3 lines (Taipa, Seac Pai Van, Hengqin) with 15 stations, rendered as 3D models with real track geometry and elevated viaducts
- 3D Bus fleet — 92 routes with road-snapped paths via OSRM, including accurate bridge geometry (Macau–Taipa bridges)
- 3D Aircraft — 176 real MFM airport flights (87 departures + 89 arrivals) with detailed airplane models (fuselage, swept wings, vertical tail in airline colors, engine nacelles, window rows, cockpit windshield); aircraft park at 12 apron stands before departure and taxi along waypoint paths before takeoff
- Landing & holding patterns — Aircraft approach from North or South with multi-waypoint landing routes; when the runway is occupied, arriving flights enter a realistic circular holding pattern above the airport and smoothly transition back to the landing route when clear
- 3D Ferries — 6 sea routes (Hong Kong Outer Harbour / Taipa / Sheung Wan, HKIA, Shenzhen Airport, Shekou) served by TurboJET and CotaiJet, rendered as jetfoil models (pontoon hull, red belt, white TurboJET band, cabin, windows, wheelhouse, roof) following great-circle paths with wake-aware headings
- Real-time simulation — Vehicles move along routes based on timetables, service frequencies, and schedule types (Mon–Thu / Friday / Sat–Sun)
- ETA & vehicle info — Click any vehicle or station to see live ETAs, next arrivals, route details, and service status
- Flight info — Click any aircraft to see flight number, airline, destination/origin (with localized names), scheduled time, aircraft type, and live/sim status
- Ferry info — Click any ferry to see operator, route, origin/destination port (localized), scheduled departure, crossing time, and live progress
- Parishes — Macau's seven civil parishes (花地瑪堂區 / 花王堂區 / 望德堂區 / 大堂區 / 風順堂區 on the peninsula, 嘉模堂區 on Taipa, 聖方濟各堂區 on Coloane) plus the Cotai reclamation zone, which belongs to no parish, drawn as a faint tint with a thin boundary and the area's name in the reading language (the name steps out above zoom 15.5, where a parish label across a street block is noise). Boundaries from OpenStreetMap, land area and the 2021 census population from DSEC. Unlike every other CITY layer this one is context, not data: it is anchored below the basemap's roads so streets, buildings, vehicles and every other overlay draw on top of it, it stacks with everything (it is not a focus mode), and it never takes a click from anything above it — clicking a school block inside a parish opens the school. Click empty ground inside an area for its name in all three languages, kind, island, area, population with the census year, and density; toggleable. While the layer is on, the LRT lines and bus routes are hidden and put back when it goes off; switching a line back on drops the tint instead.
- Road-works notices — DSAT traffic-diversion notices shown on the map for the simulated date, toggleable
- School buildings — Every school and tertiary campus rendered as coloured 3D blocks. Colour carries two facts: the hue is the teaching level (kindergarten fuchsia / primary crimson / secondary blue / university leaf green / all-through gold — a hue set chosen to stay clear of the public-housing families, since the two overlays can be read together), and the shade is the era the school was founded, from before 1900 (darkest) to 2000 onwards (lightest); a school with no known year takes the middle shade. Founding years are hand-transcribed by the pipeline from DSEDJ school profiles, the schools' own sites and zh.wikipedia. The legend section collapses, each level can be switched on/off on its own and carries its five-stop ramp; click a block for the school's name, level, system, founding year and approved stages
- Public housing — The Housing Bureau's social (社會房屋) and economic (經濟房屋) housing estates as 3D footprints, plus the government's elderly apartments (長者公寓) and the urban-renewal replacement (置換房) and temporary (暫住房) housing as a third colour — sandwich-class (夾心房屋) housing belongs to the same group but has no estate in the data yet. Coloured by type and shaded by the decade each block was first occupied; the legend section collapses and each type can be switched on/off on its own; click a block for the estate's name, type, category, district, address, occupation year, units, storeys and per-block dates. Footprints from OpenStreetMap. A focus mode like waste, water and power (the five are mutually exclusive), with one exception: switching it on hides the other layers — LRT, buses, air, sea, parishes, road works, toilets, car parks, waste — along with the clock and time controls, but leaves schools on screen and freely toggleable, so estates can be read against their catchments; switching it off restores the rest exactly as it was and leaves the schools as you have them
- Public toilets — IAM public toilets as map markers with opening hours, barrier-free / family cubicles and temporary closures; toggleable
- Public car parks — DSAT's 88 public car parks as map markers with entrances, height limits and fees, plus live vacancy shown only while the clock is at the present; toggleable
- Waste & recycling — IAM's refuse rooms, compacting bins, refuse stations and (via its own facility map) glass-bottle and clothing recycling banks, plus DSPA's smart recycling machines, three-colour recycling points, e-waste points and lamp/battery points — ≈1,157 collection points across nine types; DSPA's 10 Eco Fun drop-off stations; and a treatment-facilities layer covering the 澳門垃圾焚化中心 incineration plant, the hazardous-waste station, two landfills and the territory's 5 sewage treatment plants, all drawn as coloured 3D buildings or filled outlines — ≈1,176 marks across twelve toggleable key rows. The incinerator, the hazardous station, the construction-waste landfill and each sewage plant carry a monthly bar chart (tonnes received or cubic metres treated) from a small dedicated stats file, best-effort if DSPA's API is unreachable. Seven recycling-heavy rows start hidden so the ~300 collection/treatment points are not buried; switch them on from the key. A focus mode like public housing, water and power (the five are mutually exclusive): switching it on hides every other layer — LRT, buses, air, sea, parishes, road works, schools, public housing, toilets, car parks — along with the clock and time controls, and restores them exactly as they were when it's switched off
- Water supply facilities — Macao Water's 22 plants, reservoirs, elevated tanks and pumping stations, plus the government's own Hac Sa Reservoir; footprints coloured by type where OSM has them, markers for the rest flagged approximate, connected by a schematic pipe network drawn along the roads (from two raw-water inlets: the Ilha Verde border canal, and a schematic Lotus Bridge point for the raw water that arrives via Hengqin, flagged as such) and a Macau-only distribution network along every road. Every marker carries its step number in the supply chain, the mains carry direction arrows, and a bright pulse walks the whole chain in order — inlet, reservoir, raw-water pump, plant, pump, elevated tank, then outward through the streets — so the sequence reads, not just the direction. Switching it on is a focus mode: every other layer (LRT, buses, air, sea, parishes, the other city overlays) is hidden along with the clock and time controls, and everything comes back exactly as it was when the layer is switched off
- Electricity grid — CEM's power station, the incineration plant and 33 HV substations (220 / 110 / 66 kV) with a schematic grid drawn along the roads and the three Guangdong interconnection inlets. Same reading aids as the water layer: every marker carries its step in the supply chain (① sources — import points, power station, incinerator — then ② 220 kV, ③ 110 kV, ④ 66 kV, ⑤ the streets), the lines carry direction chevrons, and a pulse walks the grid in that order; a focus mode like public housing, water and waste (the five are mutually exclusive)
- Grand Prix circuit — the Guia Circuit of the Macau Grand Prix: the 6.2 km racing line stitched from OpenStreetMap's
Circuito da Guiarelation (cross-checked against the organiser's official lap length), the pit lane, direction chevrons, the nine officially named corners in race order (names quoted from the Macau Grand Prix Committee in all three languages; positions derived from the track geometry by a stated rule and flagged as schematic), and a single open-wheel car lapping on the simulation clock in the record time — braking for the hairpin and running out along the straight on a speed profile derived from the track's curvature, stretched so every lap takes exactly the record — with a fading wake behind it and its live speed beside it. A focus mode like public housing, water, power and waste (the five are mutually exclusive) that flies the map to the circuit when switched on (never below zoom 14.4), except that the clock and speed controls stay on screen: the car is the one thing in a focus mode with a time dimension, and 10× turns a two-minute lap into twelve seconds - Layer panel — desktop LAYERS panel split into TRANSIT (LRT / Bus / Air / Sea) and CITY (parishes / road works / car parks / toilets / schools, then the FOCUS group: public housing / water / power / waste / grand prix) pages; every switch and the open page persist in localStorage; road works on by default, the other city layers (parishes included) off. Five of the CITY rows — public housing, waste, water, power and grand prix — are mutually exclusive focus modes: each remembers the layers it hid and puts them back when switched off (public housing leaves the schools alone in both directions)
- Automated ferry data — GitHub Actions workflow scrapes TurboJET and CotaiJet timetables monthly and commits updated schedules if changed
- Time controls — Play, pause (spacebar), speed up (1×–60×), jump to current time, or pick any date/time with the DateTimePicker; Esc toggles the sidebar menu
- Vehicle tracking — Click a vehicle to follow it with smooth camera animation; freely zoom/pan while tracking
- Route visibility — Toggle individual bus routes by group (Peninsula, Cross-Harbour, Taipa/Cotai, Night, Special); auto-mode shows only routes currently in service
- 3D/2D toggle — Switch between perspective and top-down views
- Dark/Light mode — Two map styles (CARTO Dark Matter / Positron)
- Trilingual UI — English / 繁體中文 / Português — flight destinations, station names, and all labels switch with the language
- Cyberpunk-styled menu — Hamburger menu with Orbitron-font title and gradient branding
- Responsive mobile UI — Hamburger menu for map controls, a chip stack for LRT / Bus / Air / Sea plus one CITY chip that opens a list of the four city layers (each keeps its own modal), optimized touch layout with safe-area support, and Add to Home Screen (a web app manifest plus an install card that names the Share / menu route for iOS and Android browsers)
- Lazy loading — Code-split panels (VehicleInfoPanel, StationInfoPanel, FlightInfoPanel, RoadWorkInfoPanel, SchoolInfoPanel, PublicHousingInfoPanel, ParishInfoPanel, ToiletInfoPanel, CarParkInfoPanel, WasteSiteInfoPanel, WasteIncineratorInfoPanel, WasteEcoStationInfoPanel, WasteFacilityInfoPanel) for fast initial load
- Automated flight data — GitHub Actions workflow syncs MFM flight schedules from the AviationStack API daily
Upstream sources are normalized into JSON, loaded through static assets and schedule-specific API requests, and replayed by the browser on a simulated clock. The DSAT car-park vacancy API provides live updates only while the clock sits at the present.
Animated SVG (SMIL, no scripts) — generated, see docs/architecture.svg.
| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript 6, Vite 8 |
| 3D Map | MapLibre GL JS 6 (WebGL 2 required), custom fill-extrusion layers |
| Geo utilities | Turf.js (nearest-point-on-line) + custom precomputed-polyline cache |
| Styling | Tailwind CSS v4 |
| Fonts | Orbitron, JetBrains Mono, Noto Sans HK (Google Fonts) |
| Data pipeline | Python 3.13+, uv, OpenStreetMap Overpass API, OSRM |
| Flight data | AviationStack API (daily sync) |
| Ferry data | TurboJET + CotaiJet timetables (monthly web scraper) |
| City data | data.gov.mo — DSAT road works, DSAT car parks + live vacancy (daily syncs); IAM toilets, IAM/DSPA waste & recycling points (monthly syncs); DSEDJ school list, IH's public-housing lists, Macao Water's facility list and CEM's substation list, all + OSM footprints (manual); DSEC 2021 census + OSM boundaries for the parishes (manual) |
| Data validation | zod schemas at load time, mirrored by validate_output.py in CI |
| Deployment | Cloudflare Pages (via GitHub Actions) |
| Analytics | Google Analytics (gtag.js) |
npm install
npm run devThe app will be available at http://localhost:5173.
npm run build
npm run previewPhones have no console, so the app carries its own. Append ?debug=1 to any URL (or set localStorage mini-macau-debug to 1) and a panel pins to the bottom of the page (src/debugOverlay.ts): the browser's capabilities (the existing map WebGL 2 context and renderer, GL limits, a module-worker probe), every error and unhandled rejection, MapLibre error events with their source and tile, a heartbeat every 3 s (alive, canvas size, shaders and programs compiled, map renders, tile reloads with the four busiest sources named), and a pinned strip for the lines that decide a diagnosis — a failed shader with its info log and whether the context was lost. The previous page load's tail is kept in localStorage and replayed on the next load, so a page the OS killed still leaves a trace.
Switches for narrowing a failure down without a redeploy:
| URL | What it does |
|---|---|
?debug=1&layers=none |
Basemap only: none of the app's sources or layers |
?debug=1&nosim=1 |
Never runs the simulation tick |
?debug=1&no3d=1 |
Starts flat, buildings off |
?debug=1&maxdpr=2 |
Caps the render pixel ratio |
?debug=1&nowebgl2=1 |
Pretends the device has no WebGL 2: switches to the raster compatibility map |
?map=2d |
Opens the compatibility map directly, using raster tiles and Canvas2D overlays |
On context loss or shader failure the app rebuilds the map once, retaining its camera. Repeated failure switches to a 2D compatibility map with routes, moving vehicle points, city markers and the existing time/layer controls. It does not require WebGL; 3D buildings and animated network flows are unavailable. Debugging observes the map's existing context without creating probe contexts, and reports null shader query results separately from false compile status. See recovery behavior and investigation.
public/gltest.html is a page with no app code at all: MapLibre 5.23 or 6.7 straight from a CDN (?v=5|6), the same CARTO basemap and camera, and a synthetic fill-extrusion + circle setData load (&veh=150&hz=30&circles=300; &veh=0&circles=0 for the basemap alone; &theme=light, &dpr=2, &buildings=1, &overscale=off). It separates "MapLibre on this device" from "our app". Both pages are noindex.
Transit geometry and other static datasets are pre-generated in public/data/. LRT movement is computed by the Pages Function. The browser loads fixed two-minute state windows through /api/lrt/state?at=<epoch-ms> and prefetches the next overlapping window for playback and seeking.
Regenerate transit data
cd data
# Set up Python environment
uv sync
# Run all data extraction scripts
uv run python main.pyThis will:
- Extract LRT track geometry from OpenStreetMap (
railway=light_railways) - Extract bus routes and stops from OpenStreetMap + motransportinfo.com reference data
- Fetch bridge approach geometry for accurate cross-harbour routing
- Snap bus routes to roads via OSRM with custom bridge geometry patching
- Generate timetables based on published service frequencies
- Write the JSON straight to where it is consumed:
public/data/, served as-is. There is no intermediatedata/output/copy to sync.
Flight data sync
Flight schedules are fetched from the AviationStack API and stored as a static JSON file:
cd data
# Fetch today's MFM flights (requires API key)
AVIATIONSTACK_API_KEY=your_key uv run python scripts/fetch_flights.py
# Fetch a specific date
AVIATIONSTACK_API_KEY=your_key uv run python scripts/fetch_flights.py 2026-04-19The sync:
- Pulls arrivals and departures for MFM (IATA:
MFM) from the AviationStack flights endpoint - Filters by the target date's active schedule
- Validates aircraft type codes (ICAO format like A320, B738)
- Outputs
public/data/flights.jsonwith times in Macau local (UTC+8)
This is also automated via GitHub Actions (.github/workflows/update-flights.yml), which runs daily at 04:00 Macau time (UTC+8) and commits updated flight data if changed.
Ferry schedule scraper
Ferry timetables are scraped from the operator sites and stored as a single static JSON file with 6 routes across two operators (TurboJET and CotaiJet):
cd data
# Scrape the current month's schedules for all routes
uv run python scripts/fetch_ferry_schedules.pyThe scraper:
- Pulls TurboJET schedules for Hong Kong (Outer Harbour), Hong Kong (Taipa), HKIA, Shenzhen Airport, and Shekou
- Pulls CotaiJet schedule for Hong Kong (Sheung Wan) ↔ Macau Taipa
- Records
fetchedAtUtcandeffectiveAsmetadata so stale data is easy to spot - Outputs
public/data/ferry-schedules.json
Automated via GitHub Actions (.github/workflows/update-ferry-schedules.yml), which runs on the 1st of each month at 00:00 UTC (08:00 Macau) and commits updates if changed.
- LRT tracks & stations — OpenStreetMap (railway=light_rail relations)
- LRT timetables — MLM 澳門輕軌股份有限公司 official per-station timetable publications (Taipa / Seac Pai Van / Hengqin lines), transcribed and checked for timetable-driven playback
- Bus routes & stops — OpenStreetMap + motransportinfo.com curated stop data
- Road-snapped routes — OSRM with custom bridge approach geometry
- Bus timetables — Based on published DSAT service frequencies
- Flight schedules — AviationStack API (MFM arrivals + departures)
- Ferry schedules — TurboJET + CotaiJet official monthly timetables
- Parishes — OpenStreetMap administrative boundaries (the seven freguesias plus the Cotai reclamation zone) + DSEC 2021 census population and land area by parish (manual refresh)
- Road-works notices — DSAT via data.gov.mo (daily)
- School buildings — DSEDJ school list on data.gov.mo + OpenStreetMap building footprints (manual refresh); founding years hand-transcribed from DSEDJ school profiles, the schools' own sites and zh.wikipedia
- Public housing — 房屋局 (IH) 社會房屋位置分佈 and 經濟房屋位置分佈 lists, plus the bureau's 長者公寓 / 置換房 / 暫住房 / 夾心房屋 pages for the other public-housing programmes, + OpenStreetMap building footprints (manual refresh)
- Public toilets — IAM via data.gov.mo (monthly)
- Public car parks — DSAT via data.gov.mo (daily) + live vacancy (live, polled by the browser)
- Waste & recycling — IAM 垃圾房 (refuse rooms) + 壓縮式垃圾收集點 (compactors) via data.gov.mo ZIP download, and 全澳垃圾收集設施的資訊列表 via the API gateway (垃圾站 refuse stations only — the feed's other two site types duplicate the ZIP downloads above) (monthly); DSPA 智能回收機 (smart recycling machines), 三色資源回收點 (three-colour recycling), 電腦及通訊設備回收點 (e-waste), and 光管 + 電池回收點 (lamp/battery, merged — identical site lists) via the data.gov.mo API gateway (monthly); DSPA 垃圾焚化中心 monthly statistics (monthly, best-effort — the panel just omits the stats block if the call fails) — eight datasets plus IAM's 環境資訊網 facility map JSON (
facility_c.json, not on data.gov.mo: the 5 玻璃樽公共回收點 glass-bottle and 16 全澳衣物公共回收點 clothing recycling points), ≈1,157 sites total; plus 10 hand-placed DSPA 環保加Fun站 Eco Fun stations, the hand-placed 特殊和危險廢物處理站 hazardous-waste station, and two OpenStreetMap landfill outlines (ways 552848944, 552740242) — the incineration plant's own buildings come from OpenStreetMap throughpower-facilities.jsonalready, no extra dataset. Monthly throughput for four of those — the incinerator, the hazardous-waste station, the construction-waste landfill and the territory's five sewage plants — lands in a separatedspa-stats.json: the incinerator dataset above, plus DSPA's 澳門半島污水處理廠, 氹仔污水處理廠, 路環污水處理廠 and 澳門跨境工業區污水處理站 monthly figures via the API gateway (monthly); the hazardous station's, the landfill's and the airport plant's monthly figures are published only on DSPA's GIS pages, not on data.gov.mo — every series is best-effort,nullin the file (and hidden in the panel) when a call fails - Water supply facilities — Macao Water 供水設施 (the list of 22) + OpenStreetMap footprints, plus 黑沙水庫 Hac Sa Reservoir from OpenStreetMap (a DSAMA government reservoir, not a Macao Water facility); the figures in the panel come from Macao Water's 供澳原水 and 統計數據 pages, re-read on every run (twice a year)
- Electricity grid — CEM 澳電 營運 (the substation list in Chinese and English, the year's generation/import figures and the Guangdong interconnection history, re-read on every run) + OpenStreetMap footprints; the 220/110/66 kV lines between them are our schematic, not CEM's cable routes, which are underground and unmapped (twice a year)
- Grand Prix circuit — OpenStreetMap relation 8877949 Circuito da Guia (the racing line and the pit lane) + the Macau Grand Prix Committee's circuit page (the corner names in three languages, the 6.2 km lap length and the 7 m minimum width); the lap record the car runs at is Wikipedia's figure, flagged as a secondary source in the panel; corner coordinates are ours, derived from the track geometry by a rule the file records (manual refresh)
Everything under /data/*.json is fetchable as-is but served with X-Robots-Tag: noindex, nofollow (public/_headers) so the raw files stay out of search results. It is a header rather than a robots.txt Disallow on purpose: a crawler that is disallowed never sees the noindex, and a disallowed URL can still be listed bare when something links to it.
Not every layer is equally fresh. LRT and buses are fully simulated from published timetables; flights and ferries are static syncs on their own schedule. None of the transit layers below touch a live feed.
| Layer | Mode | Source | Refresh cadence | Staleness indicator |
|---|---|---|---|---|
| LRT | Simulated | OSM geometry + MLM published per-station timetable | Manual updates following official publications | Schedule-specific API; no live vehicle feed |
| Bus | Simulated | OSM geometry + DSAT published service frequencies, dimmed by a daily service-status scrape | Manual regen (routes) · daily (service-status.yml) |
DSAT stop snapshot timestamp in data/bus_reference/dsat_stops.json (current: 2026-09-02 Macau) |
| Flights | Static daily sync | AviationStack API | Daily at 04:00 Macau time — update-flights.yml |
fetchedAtUtc embedded in flights.json |
| Ferries | Static monthly sync | TurboJET + CotaiJet timetable pages (scraped) | 1st of month · update-ferry-schedules.yml |
fetchedAtUtc + effectiveAs in ferry-schedules.json |
What each mode means
- Simulated — Vehicles are placed on pre-generated polylines and moved by the client clock using the published timetable. They don't reflect any single bus or train's actual position at that moment; they show "what the schedule says should be moving through this segment right now."
- Static sync — A scheduled GitHub Actions job fetches upstream data and commits a new
public/data/*.jsonif it changed. The app reads whatever was in the last build; there is no per-page-load fetch for flights or ferries.
File tree
mini-macau/
├── src/
│ ├── components/
│ │ ├── MapView.tsx # Main map + hamburger menu
│ │ ├── ControlPanel.tsx # Playback speed controls
│ │ ├── TimeDisplay.tsx # Clock + DateTimePicker trigger
│ │ ├── DateTimePicker.tsx # Date/time selection overlay
│ │ ├── LineLegend.tsx # Layer legend — desktop TRANSIT/CITY pages + mobile chips
│ │ ├── VehicleInfoPanel.tsx # Vehicle detail + ETA
│ │ ├── StationInfoPanel.tsx # Station detail + next arrivals
│ │ ├── FlightInfoPanel.tsx # Flight detail panel
│ │ ├── FerryInfoPanel.tsx # Ferry detail panel
│ │ ├── RoadWorkInfoPanel.tsx # Road-work notice detail panel
│ │ ├── SchoolInfoPanel.tsx # School building detail panel
│ │ ├── PublicHousingInfoPanel.tsx # Public-housing estate detail panel
│ │ ├── ParishInfoPanel.tsx # Parish / reclamation-zone detail panel
│ │ ├── ToiletInfoPanel.tsx # Public toilet detail panel
│ │ ├── CarParkInfoPanel.tsx # Car park detail + live vacancy panel
│ │ ├── WasteSiteInfoPanel.tsx # Waste site/incinerator/eco-station/facility panels (4 exports)
│ │ └── StatsChart.tsx # Shared monthly bar chart (incinerator/hazardous/landfill/WWTP panels)
│ ├── engines/
│ │ └── simulationEngine.ts # Timetable-driven vehicle + flight position computation
│ ├── data/
│ │ └── hourDensity.ts
│ ├── hooks/
│ │ ├── useSimulationClock.ts # RAF-based clock with speed/pause
│ │ ├── useTransitData.ts # JSON data loader
│ │ └── useCarParkVacancy.ts # Live car-park vacancy polling (1x + tab visible only)
│ ├── layers/
│ │ ├── Bus3DLayer.ts # 3D bus model (fill-extrusion)
│ │ ├── LRT3DLayer.ts # 3D LRT model (fill-extrusion)
│ │ ├── Flight3DLayer.ts # 3D airplane model (fill-extrusion)
│ │ ├── Ferry3DLayer.ts # 3D jetfoil model (fill-extrusion, 8 layers)
│ │ ├── RaceCar3DLayer.ts # 3D open-wheel car for the Grand Prix layer (fill-extrusion, moved by diff)
│ │ └── VehicleLayer.ts # 2D vehicle circles + labels (VEHICLE_SOURCE_MAXZOOM shared by the vehicle sources)
│ ├── App.tsx # Root layout + state management
│ ├── main.tsx # React entry point with I18nProvider
│ ├── debugOverlay.ts # ?debug=1 on-screen diagnostics (capabilities, errors, heartbeat, pinned lines)
│ ├── routeGroups.ts # Bus route grouping logic
│ ├── roadWorks.ts # Road-works notice helpers (status, colours)
│ ├── schools.ts # School overlay helpers (level + founding-era colours, footprint features)
│ ├── publicHousing.ts # Public-housing overlay helpers (type + decade colours, footprint features)
│ ├── parishes.ts # Parish overlay helpers (per-area tints, area + label features, density)
│ ├── toilets.ts # Public-toilet overlay helpers (variant, marker features)
│ ├── carParks.ts # Car-park overlay helpers + live-vacancy XML parsing
│ ├── waste.ts # Waste & recycling overlay helpers (colours, text pickers, visible-site filtering)
│ ├── dspaStats.ts # DSPA monthly-stats chart model (axis rounding, series lookup)
│ ├── water.ts # Water overlay helpers (supply-chain stages, labels, pulse buckets)
│ ├── power.ts # Power overlay helpers (stages, voltages, grid features)
│ ├── flowPulse.ts # Shared pulse engine for the water / power flows
│ ├── grandPrix.ts # Guia Circuit: track & corner features, speed profile, car pose, wake
│ ├── focusMode.ts # HOUSING / WATER / POWER / WASTE / GRAND PRIX focus-mode snapshots + per-layer exemptions
│ ├── theme.ts # dark / light theme store (data-theme on <html>)
│ ├── timeControls.ts # Pure rule for when the clock's keyboard shortcuts are suppressed (focus modes)
│ ├── macauTime.ts # All wall-clock math (Macau, UTC+8)
│ ├── dataSchemas.ts # zod schemas for public/data/*.json (mirrors validate_output.py)
│ ├── i18n.tsx # Internationalization (EN / 繁中 / PT)
│ ├── types.ts # TypeScript interfaces
│ └── index.css # Tailwind + MapLibre control overrides
├── public/
│ ├── _headers # X-Robots-Tag: noindex for /data/* and /gltest.html
│ ├── gltest.html # Standalone MapLibre 5/6 device test page (no app code)
│ ├── data/ # served as-is under /data/
│ │ ├── lrt-lines.json
│ │ ├── stations.json
│ │ ├── bus-routes.json
│ │ ├── bus-stops.json
│ │ ├── flights.json # MFM flight schedules (with localized names)
│ │ ├── ferry-schedules.json # TurboJET + CotaiJet monthly timetables
│ │ ├── road-works.json # DSAT road-works notices
│ │ ├── schools.json # School buildings + footprints
│ │ ├── public-housing.json # IH social + economic housing estates and footprints
│ │ ├── parishes.json # 7 civil parishes + the Cotai reclamation zone (OSM boundaries, DSEC census)
│ │ ├── toilets.json # IAM public toilets
│ │ ├── car-parks.json # DSAT public car parks
│ │ ├── waste.json # IAM + DSPA sites, eco stations, treatment facilities (incl. 5 WWTPs)
│ │ ├── dspa-stats.json # DSPA monthly stats: incinerator, hazardous station, landfill, 5 WWTPs
│ │ ├── water-facilities.json # Macao Water supply facilities + footprints
│ │ ├── water-distribution.json # Macau-only road network for the water layer
│ │ ├── power-facilities.json # CEM power station, incinerator, HV substations + schematic grid
│ │ ├── power-distribution.json # Macau-only road network for the power layer
│ │ └── grand-prix.json # Guia Circuit: OSM racing line + official corner names
│ ├── favicon.svg
│ ├── icons.svg
│ ├── og-image.png
│ ├── sitemap.xml
│ └── robots.txt
├── data/
│ ├── scripts/
│ │ ├── extract_lrt_osm.py
│ │ ├── extract_bus_data.py
│ │ ├── fetch_bus_data.py
│ │ ├── fetch_bridge_geometry.py
│ │ ├── fetch_flights.py # AviationStack flight data sync (MFM)
│ │ ├── fetch_ferry_schedules.py # TurboJET + CotaiJet monthly scraper
│ │ ├── fetch_road_works.py # DSAT road-works notice sync
│ │ ├── fetch_schools.py # DSEDJ school list + OSM footprints (manual)
│ │ ├── fetch_public_housing.py # IH social + economic housing lists + OSM footprints (manual)
│ │ ├── fetch_parishes.py # OSM parish boundaries + DSEC census figures (manual)
│ │ ├── fetch_water_facilities.py # Macao Water's 22 facilities + OSM footprints (twice a year)
│ │ ├── macao_water.py # reads Macao Water's site: the year's statistics, the raw-water facts, the plant names, zh/en/pt
│ │ ├── fetch_water_distribution.py # Macau-only road canvas, oriented from the water sources (manual)
│ │ ├── fetch_power_facilities.py # CEM substations + OSM footprints + schematic grid (twice a year)
│ │ ├── cem_operation.py # reads CEM's operation page: the year's figures + the substation names, zh and en
│ │ ├── fetch_power_distribution.py # the same road canvas, oriented from the substations (manual)
│ │ ├── fetch_grand_prix.py # Guia Circuit from OSM + the organiser's corner names (manual)
│ │ ├── road_network.py # Shared Macau-only road canvas (clip, simplify, flow field)
│ │ ├── osm_footprints.py # Shared Overpass + basemap-tile footprint helpers
│ │ ├── fetch_toilets.py # IAM public-toilet sync
│ │ ├── fetch_car_parks.py # DSAT public car-park sync
│ │ ├── fetch_waste.py # IAM + DSPA waste & recycling sync
│ │ ├── fetch_dspa_stats.py # monthly via update-dspa-stats.yml
│ │ ├── osrm_route.py
│ │ └── patch_bus_bridges.py
│ ├── bus_reference/
│ └── main.py
├── functions/
│ └── api/lrt/[stype].ts # Pages Function — bounded vehicle state endpoint
├── plugins/
│ └── lrt-dev-api.ts # Dev-only stand-in for the Function above
├── .github/workflows/
│ ├── deploy.yml # Cloudflare Pages CI/CD
│ ├── service-status.yml # Upstream service availability check
│ ├── update-flights.yml # Daily flight data update
│ ├── update-ferry-schedules.yml # Monthly ferry data update
│ ├── update-road-works.yml # Daily road-works notice update
│ ├── update-toilets.yml # Monthly public-toilet update
│ ├── update-car-parks.yml # Daily car-park update
│ ├── update-waste.yml # Monthly waste & recycling update
│ └── update-dspa-stats.yml # Monthly DSPA statistics update
└── index.html
Simulating 300–400 moving vehicles at 30 Hz while MapLibre re-draws 3D extrusions every frame puts real pressure on the main thread. A few optimizations worth calling out:
Bus traffic runs in a dedicated worker
Both the 3D map and the 2D fallback calculate bus following, junction reservations and swept-body checks in a module worker. Accelerated playback uses aligned batches, with route traces transferred back for interpolation between replies. Visible models use detailed lane samples and an additional presentation collision check. There is at most one request in flight: clock updates coalesce instead of building a queue of obsolete frames. Each job advances at most eight simulated seconds, retaining queues and reservations while catching up. If the worker persistently falls behind during accelerated playback, the selected speed decreases to the next available rate; the controls and clock reflect that rate. Seeks and layer changes reject stale replies; pausing freezes the latest checked position, including while the camera moves. If workers are unavailable, the traffic engine runs synchronously.
Only bus routes and stops are sent to the worker. Route objects retain stable identities across visibility changes, and unchanged data is not copied on each tick. Traffic near the camera and tracked vehicle retains 0.5-second physics steps, with a surrounding approach buffer. Distant map markers follow schedule positions until they enter that buffer; existing queues remain selected by their physical positions. This bounds detailed work by the viewed area rather than the whole fleet. See asyncBusFrame.ts and busWorkerRuntime.ts.
The clock is an external store, not App state
The simulation clock ticks ~10 times a second. It used to publish the time as React state on the hook that App owns, so every tick re-rendered App and its whole tree — the layer panel with its hundreds of rows included — to move a seconds digit. In the dev build that was 30–40 ms of React work ten times a second.
Now useSimulationClock publishes the time through a tiny store (subscribeTime / getTimeMs) and components pick their own resolution with useSyncExternalStore: useClockTime re-renders on every tick and is used only by the clock face and the scrubber; useClockMinute snapshots the simulated minute, so App and the info panels — which decide by service windows, the day's flights and minute-level ETAs — re-render once per simulated minute (once a second at 60×). Per-frame consumers (the engine, the 3D layers) never rendered off the clock at all; they read timeRef. See useSimulationClock.ts.
Polyline progress lookup — cumKm + binary search
The simulation asks the same question once per vehicle per tick: given a route and a progress ∈ [0, 1], where on the polyline is the vehicle, and which way is it facing?
The original implementation used Turf's along twice per vehicle (once for position, once for a 1-metre-ahead lookahead to derive bearing). along walks the coordinate array from index 0 and sums haversine distances until it reaches the target km — O(n) haversines per call. At ~400 vehicles × 2 calls × 20 Hz × 100-point routes, that worked out to roughly 12 000 full-route scans per second, all on the main thread.
Key observation: each route's geometry is immutable, so the per-segment work only needs to happen once. On first touch we cache:
cumKm[i]— cumulative kilometres fromcoords[0]tocoords[i](Float64Array)segBearing[i]— heading of segmentcoords[i] → coords[i+1](Float64Array)
Per-call cost then collapses to a binary search on cumKm (≈ 8 comparisons for a 150-point route), a linear interpolation between two lat/lng pairs, and a table lookup for bearing. No trig in the hot loop, and no second along call since the segment index already tells us the heading.
We deliberately don't cache a per-line "last index" hint: multiple vehicles share the same polyline at different progress values, so a shared hint would thrash. O(log n) is cheap enough that per-vehicle state isn't worth it. See simulationEngine.ts (getLineCache / interpolateOnLine).
One bus-routes source instead of 92
MapLibre GeoJSON sources are tiled in a web worker: the worker clips each source's features to tile boundaries, tessellates lines into triangle strips, and ships vertex buffers back to the main thread. Originally each of the 92 bus routes was its own addSource + addLayer, meaning every zoom level change forced 92 separate postMessage round-trips and 92 independent tile-index rebuilds.
Consolidating into a single bus-routes source (one tile index, one round-trip per reindex) drastically cut worker chatter during zoom. Per-route dimming — previously setPaintProperty('bus-route-${id}', 'line-opacity', …) against 92 layers — became setFeatureState({ source: 'bus-routes', id }, { inService }) on one layer, with opacity driven by a ['case', ['==', ['feature-state', 'inService'], false], DIM, FULL] paint expression. setFeatureState doesn't recompile paint; setPaintProperty does.
Two-tier animation throttle
The bus, aircraft and LRT models use shared instanced meshes. CPU instance arrays and GPU buffers are reused for subsequent poses; buffer storage is reallocated only when a batch grows or its WebGL context is rebuilt. Unchanged visible bus snapshots skip both mesh and picking-source uploads, including camera moves that leave the visible fleet unchanged.
The animate loop samples surface positions and requests available bus-worker updates every 33 ms. GeoJSON setData still re-tiles each source's in-view tiles and uploads their buffers, so the 3D picking sources and the 2D marker source share one upload cadence: 33 ms on desktop, 100 ms on phones, and 160 ms whenever the map is actively moving (movestart / moveend set a mapBusy flag). The 2D marker source used to be written every animation frame, 60 re-tilings a second of a source that only changes at the sim tick; on an iPhone X that was 450 tile reloads a second and a lost WebGL context.
Fewer tiles per setData
Once the cadence was fixed, that iPhone X still re-tiled ~450 tiles a second at zoom 16, and the ?debug=1 heartbeat — which names the busiest sources — showed why: a pitched phone view holds ~20 z16 tiles per GeoJSON source, and every setData reloads all of them, so the cost is tiles × sources × cadence. Three changes attack the tile count rather than the cadence:
- Vehicle sources are tiled to z15 (
VEHICLE_SOURCE_MAXZOOMinVehicleLayer.ts): a zoom-16 view is a handful of z15 tiles instead of ~20 z16 ones, 4× fewer again per level above, at a coordinate quantisation of ~0.14 m — half a pixel at zoom 18. - The Grand Prix car, wake and speed label move by diff. They are written whole once when they appear and then updated with
GeoJSONSource.updateData, which reloads only the tiles the changed feature touches (one or two) instead of every tile in view. Stable feature ids make that possible: the car's twelve boxes are ids 0–11. - MapLibre 6's
zoomLevelsToOverscaleis switched off (undefined, the v5 behaviour). Its default of 4 slices a vector source's z14 tiles into sub-tiles down to z18 instead of scaling the one parent tile; at zoom 16 / pitch 45 that was 44 tile loads instead of 8 and 2.3× the live GPU buffers for the same view.
Measured on the same phone at the same view: 457 → 110 tile reloads a second, 60 fps, no shader failures in that run. Later device tests still lost context on pure basemaps at DPR 1; these load reductions are not a demonstrated fix for persistent context loss (see the recovery investigation above). See MapView.tsx (HEAVY_TICK_MS_PHONE, writeGrandPrixWake, zoomLevelsToOverscale) and RaceCar3DLayer.ts (setPose).
Decouple zoom display from React re-renders
The zoom indicator in the HUD used to be a useState, so every map.on('zoom', …) event caused <MapView> to re-render — which is a huge component with map refs, ETA panels, and layer toggles. Now zoom lives in an external store read via useSyncExternalStore, and only a tiny <ZoomText> leaf subscribes. The rest of <MapView> stays stable during pinch/scroll zoom.
Inspiration
- Mini Tokyo 3D — Original inspiration for the concept
- Mini Taiwan — Sister project inspiration
Data sources
- OpenStreetMap — LRT track geometry, bus routes, and stop locations
- MLM 澳門輕軌股份有限公司 — Official per-station LRT timetables, hand-transcribed for the Taipa / Seac Pai Van / Hengqin lines
- MoTransport Info — Curated Macau bus stop reference data
- AviationStack — MFM flight schedule data (arrivals + departures)
- TurboJET — Ferry timetable (Hong Kong, HKIA, Shenzhen Airport, Shekou routes)
- CotaiJet — Ferry timetable (Hong Kong ↔ Macau Taipa route)
Libraries, tiles, and fonts
- MapLibre GL JS — Open-source map rendering
- CARTO — Basemap tiles (Dark Matter / Positron)
- OpenFreeMap — 3D building tiles
- OSRM — Road routing engine
- Turf.js — Geospatial analysis
- Google Fonts — Orbitron, JetBrains Mono, Noto Sans HK


