From 02e535cb556035f3918982a5063fddff40f8c626 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:10:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(intro):=20v2=20=E2=80=94=20cities,=20accel?= =?UTF-8?q?erating=20sweep,=20detections,=20thriller=20sound=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - City engine: Esri ground, OSM buildings that rise with the scan, road ribbons carrying modelled traffic coloured by congestion, NYC DOT cameras, real ADS-B, MBTA GTFS lines + realtime vehicles, LAX runways with approach strobes, Fort Bragg restricted volumes, Everest terrain with contours. - Indexing keeps accelerating into a 260-capture global sweep while the planet whips a full turn. - The eye opens: aperture blades, sensor array waking, optics calibration, radar sweep, orbiting coordinate ring. - Camera wall with YOLOv8n detections and a city-pulse trace. - Ends on GOD'S EYE VIEW / OMNISCIENCE. - audio/sfx.py: sound design only, built to sit under a music track. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KqP857pippaUs6konSDfe8 --- intro-sequence/.gitignore | 10 + intro-sequence/audio/cues.json | 2740 +++++++++-------- intro-sequence/audio/sfx.py | 527 ++++ intro-sequence/src/city.js | 915 ++++++ intro-sequence/src/hud.js | 514 +++- intro-sequence/src/main.js | 181 +- intro-sequence/src/timeline.js | 91 +- intro-sequence/tools/city/check.py | 79 + intro-sequence/tools/city/fetch_imagery.py | 107 + intro-sequence/tools/city/fetch_osm.py | 495 +++ intro-sequence/tools/city/geo.py | 288 ++ intro-sequence/tools/city2/build_scene.py | 188 ++ intro-sequence/tools/city2/common.py | 571 ++++ intro-sequence/tools/export-cues.mjs | 6 +- intro-sequence/tools/finalize-v2.sh | 23 + intro-sequence/tools/flurry/build.py | 175 ++ .../tools/flurry/city_candidates.json | 2496 +++++++++++++++ intro-sequence/tools/flurry/landmarks.py | 205 ++ intro-sequence/tools/flurry/locate.py | 78 + intro-sequence/tools/flurry/rejects.json | 32 + intro-sequence/tools/flurry/review.py | 69 + intro-sequence/tools/flurry/select_cities.py | 109 + intro-sequence/tools/flurry/tiles.py | 132 + intro-sequence/tools/live/build_transit.py | 82 + intro-sequence/tools/live/common.py | 15 + intro-sequence/tools/live/detect.py | 76 + intro-sequence/tools/live/fetch_adsb.py | 72 + .../tools/live/fetch_mbta_vehicles.py | 21 + intro-sequence/tools/live/fetch_nyc_cams.py | 53 + intro-sequence/tools/render-all.sh | 13 +- 30 files changed, 8921 insertions(+), 1442 deletions(-) create mode 100644 intro-sequence/audio/sfx.py create mode 100644 intro-sequence/src/city.js create mode 100644 intro-sequence/tools/city/check.py create mode 100644 intro-sequence/tools/city/fetch_imagery.py create mode 100644 intro-sequence/tools/city/fetch_osm.py create mode 100644 intro-sequence/tools/city/geo.py create mode 100644 intro-sequence/tools/city2/build_scene.py create mode 100644 intro-sequence/tools/city2/common.py create mode 100755 intro-sequence/tools/finalize-v2.sh create mode 100644 intro-sequence/tools/flurry/build.py create mode 100644 intro-sequence/tools/flurry/city_candidates.json create mode 100644 intro-sequence/tools/flurry/landmarks.py create mode 100644 intro-sequence/tools/flurry/locate.py create mode 100644 intro-sequence/tools/flurry/rejects.json create mode 100644 intro-sequence/tools/flurry/review.py create mode 100644 intro-sequence/tools/flurry/select_cities.py create mode 100644 intro-sequence/tools/flurry/tiles.py create mode 100644 intro-sequence/tools/live/build_transit.py create mode 100644 intro-sequence/tools/live/common.py create mode 100644 intro-sequence/tools/live/detect.py create mode 100644 intro-sequence/tools/live/fetch_adsb.py create mode 100644 intro-sequence/tools/live/fetch_mbta_vehicles.py create mode 100644 intro-sequence/tools/live/fetch_nyc_cams.py diff --git a/intro-sequence/.gitignore b/intro-sequence/.gitignore index 10ec81feb..b15fdae38 100644 --- a/intro-sequence/.gitignore +++ b/intro-sequence/.gitignore @@ -10,3 +10,13 @@ data/*.txt audio/*.wav tools/imagery/cache/ __pycache__/ + +# fetch caches +tools/*/cache/ +# city snapshots (Esri imagery, live feeds) and review/model artefacts +/city/ +tools/flurry/review/ +tools/flurry/*.jpg +tools/live/models/ +tools/live/*.jpg +tools/*/check*.png diff --git a/intro-sequence/audio/cues.json b/intro-sequence/audio/cues.json index 5688c9904..93160dad1 100644 --- a/intro-sequence/audio/cues.json +++ b/intro-sequence/audio/cues.json @@ -8,6 +8,7 @@ "beat2": 7.35, "dishDraw": 6.3, "dishDone": 8.3, + "aperture": 7.35, "growStart": 8.7, "coastReached": 13.4, "growEnd": 15.6, @@ -15,39 +16,47 @@ "indexStart": 17, "zWave": [ 17, - 19, - 20.8 + 18.5, + 19.8 ], - "capStart": 18.3, - "capRapid": 23.4, - "capFull": 26.6, + "capStart": 18, + "capRapid": 22.6, + "flurry": 23.6, + "flurryEnd": 29.4, + "capFull": 999, "indexed": 30, "air": 30.6, - "sea": 32.2, - "mil": 34.2, - "orbit": 37, - "quake": 40.2, - "fire": 42.2, - "wind": 44, - "cable": 45.9, - "cctv": 47.4, - "cctvWall": 49, - "rapid": 50.4, - "cctvOut": 52.4, - "saturate": 52.6, - "arcs": 53.4, - "twin": 55.2, - "dishBreak": 58.4, - "omni": 59, - "cut": 60.5, - "title": 61.2, - "eyeOpen": 62, - "wordmark": 63.3, - "tagline": 65, - "online": 66.6, - "fadeOut": 70 + "sea": 32, + "mil": 33.4, + "orbit": 34.6, + "quake": 36.6, + "fire": 38, + "wind": 39.4, + "cable": 41, + "descend": 42.6, + "terrain": 43.6, + "nyc": 49, + "boston": 57, + "lax": 61.8, + "bragg": 66.4, + "cctv": 71.4, + "cctvWall": 71.4, + "rapid": 72.8, + "cctvOut": 76.4, + "saturate": 76.6, + "arcs": 77.3, + "twin": 79.1, + "dishBreak": 82.3, + "omni": 82.9, + "cut": 84.4, + "title": 85.1, + "eyeOpen": 85.9, + "wordmark": 87.2, + "tagline": 88.9, + "online": 999, + "fadeOut": 93 }, - "DURATION": 72, + "DURATION": 95, "feeds": [ { "key": "air", @@ -55,155 +64,178 @@ }, { "key": "sea", - "t": 32.2 + "t": 32 }, { "key": "mil", - "t": 34.2 + "t": 33.4 }, { "key": "orbit", - "t": 37 + "t": 34.6 }, { "key": "quake", - "t": 40.2 + "t": 36.6 }, { "key": "fire", - "t": 42.2 + "t": 38 }, { "key": "wind", - "t": 44 + "t": 39.4 }, { "key": "cable", - "t": 45.9 + "t": 41 + } + ], + "cities": [ + { + "slug": "everest", + "t0": 43.6, + "t1": 49 }, { - "key": "cctv", - "t": 47.4 + "slug": "nyc", + "t0": 49, + "t1": 57 + }, + { + "slug": "boston", + "t0": 57, + "t1": 61.8 + }, + { + "slug": "lax", + "t0": 61.8, + "t1": 66.4 + }, + { + "slug": "bragg", + "t0": 66.4, + "t1": 71.4 } ], "caps": [ { - "t": 18.3, + "t": 18, "slow": true, "lon": -73.9855 }, { - "t": 20.05, + "t": 19.6, "slow": true, "lon": 55.139 }, { - "t": 21.650000000000002, + "t": 21.1, "slow": true, "lon": 103.769 }, { - "t": 23.4, + "t": 22.6, "slow": false, "lon": 139.7005 }, { - "t": 23.72, + "t": 22.720000000000002, "slow": false, "lon": 2.295 }, { - "t": 24.0295, + "t": 22.836000000000002, "slow": false, "lon": 31.1342 }, { - "t": 24.3285, + "t": 22.948, "slow": false, "lon": 4.05 }, { - "t": 24.616999999999997, + "t": 23.056, "slow": false, "lon": -118.4085 }, { - "t": 24.894999999999996, + "t": 23.16, "slow": false, "lon": 114.17 }, { - "t": 25.162499999999994, + "t": 23.26, "slow": false, "lon": -79.5915 }, { - "t": 25.419499999999996, + "t": 23.356, "slow": false, "lon": 151.2153 }, { - "t": 25.665999999999997, + "t": 23.448, "slow": false, "lon": -80.6041 }, { - "t": 25.901999999999997, + "t": 23.536, "slow": false, "lon": -0.4543 }, { - "t": 26.127499999999998, + "t": 23.62, "slow": false, "lon": -43.2302 }, { - "t": 26.342499999999998, + "t": 23.7, "slow": false, "lon": 12.3388 }, { - "t": 26.546999999999997, + "t": 23.776, "slow": false, "lon": -115.1728 }, { - "t": 26.740999999999996, + "t": 23.848, "slow": false, "lon": 12.4539 }, { - "t": 26.924499999999995, + "t": 23.916, "slow": false, "lon": 55.2744 }, { - "t": 27.097499999999993, + "t": 23.98, "slow": false, "lon": -122.4783 }, { - "t": 27.259999999999994, + "t": 24.04, "slow": false, "lon": 37.6175 }, { - "t": 27.411999999999995, + "t": 24.096, "slow": false, "lon": 116.3917 }, { - "t": 27.553499999999996, + "t": 24.148, "slow": false, "lon": 72.84 }, { - "t": 27.684499999999996, + "t": 24.195999999999998, "slow": false, "lon": 32.3228 }, { - "t": 27.804999999999996, + "t": 24.24, "slow": false, "lon": -77.0563 } @@ -594,7681 +626,7681 @@ "vis": false }, { - "t": 20.57209215193696, + "t": 20.07209215193696, "z": 4, "pan": -1, "vis": false }, { - "t": 20.51334989854444, + "t": 20.01334989854444, "z": 4, "pan": -1, "vis": false }, { - "t": 20.59946108186852, + "t": 20.09946108186852, "z": 4, "pan": -1, "vis": false }, { - "t": 20.743224119277997, + "t": 20.243224119277997, "z": 4, "pan": -1, "vis": false }, { - "t": 20.822975059202854, + "t": 20.322975059202854, "z": 4, "pan": -1, "vis": false }, { - "t": 21.079167628184234, + "t": 20.579167628184234, "z": 4, "pan": -1, "vis": false }, { - "t": 21.195709748511046, + "t": 20.695709748511046, "z": 4, "pan": -1, "vis": false }, { - "t": 21.458699627670935, + "t": 20.958699627670935, "z": 4, "pan": -1, "vis": false }, { - "t": 21.589129288894323, + "t": 21.089129288894323, "z": 4, "pan": -1, "vis": false }, { - "t": 21.28699873217459, + "t": 20.78699873217459, "z": 4, "pan": -1, "vis": false }, { - "t": 21.163644170251438, + "t": 20.663644170251438, "z": 4, "pan": -1, "vis": false }, { - "t": 20.86831092371902, + "t": 20.36831092371902, "z": 4, "pan": -1, "vis": false }, { - "t": 20.7494193062202, + "t": 20.2494193062202, "z": 4, "pan": -1, "vis": false }, { - "t": 20.598843827235047, + "t": 20.098843827235047, "z": 4, "pan": -1, "vis": false }, { - "t": 20.473591966357745, + "t": 19.973591966357745, "z": 4, "pan": -1, "vis": false }, { - "t": 20.493193542189676, + "t": 19.993193542189676, "z": 4, "pan": -1, "vis": false }, { - "t": 20.4233140733512, + "t": 19.9233140733512, "z": 4, "pan": -1, "vis": false }, { - "t": 20.45671998908888, + "t": 19.95671998908888, "z": 4, "pan": -1, "vis": false }, { - "t": 20.710662951899728, + "t": 20.210662951899728, "z": 4, "pan": -1, "vis": false }, { - "t": 20.731788858036676, + "t": 20.231788858036676, "z": 4, "pan": -1, "vis": false }, { - "t": 20.84653636590994, + "t": 20.34653636590994, "z": 4, "pan": -1, "vis": false }, { - "t": 20.902575648181504, + "t": 20.402575648181504, "z": 4, "pan": -1, "vis": false }, { - "t": 21.192690821053223, + "t": 20.692690821053223, "z": 4, "pan": -1, "vis": false }, { - "t": 21.354752518562616, + "t": 20.854752518562616, "z": 4, "pan": -1, "vis": false }, { - "t": 21.279786056886817, + "t": 20.779786056886817, "z": 4, "pan": -1, "vis": false }, { - "t": 20.96779143602579, + "t": 20.46779143602579, "z": 4, "pan": -1, "vis": false }, { - "t": 20.99367871709223, + "t": 20.49367871709223, "z": 4, "pan": -1, "vis": false }, { - "t": 20.78770651146925, + "t": 20.28770651146925, "z": 4, "pan": -1, "vis": false }, { - "t": 20.823026080243867, + "t": 20.323026080243867, "z": 4, "pan": -1, "vis": false }, { - "t": 20.51790262804561, + "t": 20.01790262804561, "z": 4, "pan": -1, "vis": false }, { - "t": 20.414026741883358, + "t": 19.914026741883358, "z": 4, "pan": -1, "vis": false }, { - "t": 20.530687902794178, + "t": 20.030687902794178, "z": 4, "pan": -1, "vis": false }, { - "t": 20.48485892841499, + "t": 19.98485892841499, "z": 4, "pan": -1, "vis": false }, { - "t": 20.540154652621066, + "t": 20.040154652621066, "z": 4, "pan": -1, "vis": false }, { - "t": 20.613937457751817, + "t": 20.113937457751817, "z": 4, "pan": -1, "vis": false }, { - "t": 20.66691658186678, + "t": 20.16691658186678, "z": 4, "pan": -1, "vis": false }, { - "t": 20.76367930282072, + "t": 20.26367930282072, "z": 4, "pan": -1, "vis": false }, { - "t": 20.888508087066423, + "t": 20.388508087066423, "z": 4, "pan": -1, "vis": false }, { - "t": 20.82163309254432, + "t": 20.32163309254432, "z": 4, "pan": -1, "vis": false }, { - "t": 20.918165174040077, + "t": 20.418165174040077, "z": 4, "pan": -1, "vis": false }, { - "t": 20.95172354755017, + "t": 20.45172354755017, "z": 4, "pan": -1, "vis": false }, { - "t": 20.92230821307737, + "t": 20.42230821307737, "z": 4, "pan": -1, "vis": false }, { - "t": 20.774256992644354, + "t": 20.274256992644354, "z": 4, "pan": -1, "vis": false }, { - "t": 20.71654495542484, + "t": 20.21654495542484, "z": 4, "pan": -1, "vis": false }, { - "t": 20.686898981485907, + "t": 20.186898981485907, "z": 4, "pan": -1, "vis": false }, { - "t": 20.41899364242405, + "t": 19.91899364242405, "z": 4, "pan": -1, "vis": false }, { - "t": 20.412327584315943, + "t": 19.912327584315943, "z": 4, "pan": -1, "vis": false }, { - "t": 20.42414860713374, + "t": 19.92414860713374, "z": 4, "pan": -1, "vis": false }, { - "t": 20.514289502469747, + "t": 20.014289502469747, "z": 4, "pan": -1, "vis": false }, { - "t": 20.391559487864903, + "t": 19.891559487864903, "z": 4, "pan": -1, "vis": false }, { - "t": 20.383855103794495, + "t": 19.883855103794495, "z": 4, "pan": -1, "vis": false }, { - "t": 20.54107882806413, + "t": 20.04107882806413, "z": 4, "pan": -1, "vis": false }, { - "t": 20.416061681666125, + "t": 19.916061681666125, "z": 4, "pan": -1, "vis": false }, { - "t": 20.408610010923287, + "t": 19.908610010923287, "z": 4, "pan": -1, "vis": false }, { - "t": 20.670603576826363, + "t": 20.170603576826363, "z": 4, "pan": -1, "vis": false }, { - "t": 20.526606201983817, + "t": 20.026606201983817, "z": 4, "pan": -1, "vis": false }, { - "t": 20.472183741210543, + "t": 19.972183741210543, "z": 4, "pan": -1, "vis": false }, { - "t": 20.507336194508582, + "t": 20.007336194508582, "z": 4, "pan": -1, "vis": false }, { - "t": 20.557104328293853, + "t": 20.057104328293853, "z": 4, "pan": -1, "vis": false }, { - "t": 20.45571107749451, + "t": 19.95571107749451, "z": 4, "pan": -1, "vis": false }, { - "t": 20.47188330233696, + "t": 19.97188330233696, "z": 4, "pan": -1, "vis": false }, { - "t": 20.62642127777709, + "t": 20.12642127777709, "z": 4, "pan": -1, "vis": false }, { - "t": 20.525280740297127, + "t": 20.025280740297127, "z": 4, "pan": -1, "vis": false }, { - "t": 20.539165833360876, + "t": 20.039165833360876, "z": 4, "pan": -1, "vis": false }, { - "t": 20.516355178336724, + "t": 20.016355178336724, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.317919114459553, + "t": 19.817919114459553, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.330599831274586, + "t": 19.830599831274586, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.368834090080398, + "t": 19.868834090080398, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.423531278975553, + "t": 19.923531278975553, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.3868054034561, + "t": 19.8868054034561, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.459720031574705, + "t": 19.959720031574705, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.184243569887837, + "t": 19.684243569887837, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.14533460536571, + "t": 19.64533460536571, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.342993138009287, + "t": 19.842993138009287, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.366144707735813, + "t": 19.866144707735813, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.32505265421538, + "t": 19.82505265421538, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.492537536257423, + "t": 19.992537536257423, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.55036947533262, + "t": 20.05036947533262, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.459870829472244, + "t": 19.959870829472244, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.580488964310078, + "t": 20.080488964310078, "z": 4, "pan": -0.875, "vis": false }, { - "t": 20.398678788551255, + "t": 19.898678788551255, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.32121806477447, + "t": 19.82121806477447, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.226726632540014, + "t": 19.726726632540014, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.272125112917347, + "t": 19.772125112917347, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.376918429206988, + "t": 19.876918429206988, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.155102054677133, + "t": 19.655102054677133, "z": 4, "pan": -0.625, "vis": true }, { - "t": 19.94729991690967, + "t": 19.44729991690967, "z": 4, "pan": -0.625, "vis": true }, { - "t": 19.97604490947066, + "t": 19.47604490947066, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.069219982483958, + "t": 19.569219982483958, "z": 4, "pan": -0.625, "vis": true }, { - "t": 19.92682513595003, + "t": 19.42682513595003, "z": 4, "pan": -0.625, "vis": true }, { - "t": 20.247952779204898, + "t": 19.747952779204898, "z": 4, "pan": -0.625, "vis": false }, { - "t": 20.356119299764895, + "t": 19.856119299764895, "z": 4, "pan": -0.625, "vis": false }, { - "t": 20.437676129495916, + "t": 19.937676129495916, "z": 4, "pan": -0.625, "vis": false }, { - "t": 20.205603154614565, + "t": 19.705603154614565, "z": 4, "pan": -0.625, "vis": false }, { - "t": 20.486444732875892, + "t": 19.986444732875892, "z": 4, "pan": -0.625, "vis": false }, { - "t": 20.450255602682823, + "t": 19.950255602682823, "z": 4, "pan": -0.625, "vis": false }, { - "t": 20.29837830050432, + "t": 19.79837830050432, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.332098668548824, + "t": 19.832098668548824, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.146738361985886, + "t": 19.646738361985886, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.261470220783316, + "t": 19.761470220783316, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.137570033506126, + "t": 19.637570033506126, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.073834444300587, + "t": 19.573834444300587, "z": 4, "pan": -0.375, "vis": true }, { - "t": 19.84652229958318, + "t": 19.34652229958318, "z": 4, "pan": -0.375, "vis": true }, { - "t": 19.59902895948362, + "t": 19.09902895948362, "z": 4, "pan": -0.375, "vis": true }, { - "t": 19.590020151035784, + "t": 19.090020151035784, "z": 4, "pan": -0.375, "vis": true }, { - "t": 19.819495874240427, + "t": 19.319495874240427, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.004165176438324, + "t": 19.504165176438324, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.04988314875365, + "t": 19.54988314875365, "z": 4, "pan": -0.375, "vis": true }, { - "t": 20.155765719119486, + "t": 19.655765719119486, "z": 4, "pan": -0.375, "vis": false }, { - "t": 20.29839101782335, + "t": 19.79839101782335, "z": 4, "pan": -0.375, "vis": false }, { - "t": 20.465733707488436, + "t": 19.965733707488436, "z": 4, "pan": -0.375, "vis": false }, { - "t": 20.41399572255754, + "t": 19.91399572255754, "z": 4, "pan": -0.375, "vis": false }, { - "t": 20.484260991495486, + "t": 19.984260991495486, "z": 4, "pan": -0.125, "vis": true }, { - "t": 20.437899893553872, + "t": 19.937899893553872, "z": 4, "pan": -0.125, "vis": true }, { - "t": 20.2728277701215, + "t": 19.7728277701215, "z": 4, "pan": -0.125, "vis": true }, { - "t": 20.246705242704017, + "t": 19.746705242704017, "z": 4, "pan": -0.125, "vis": true }, { - "t": 20.01269707241847, + "t": 19.51269707241847, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.928389684330604, + "t": 19.428389684330604, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.768938284939093, + "t": 19.268938284939093, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.30253729639488, + "t": 18.80253729639488, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.294838937025347, + "t": 18.794838937025347, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.745843206840316, + "t": 19.245843206840316, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.864299999186905, + "t": 19.364299999186905, "z": 4, "pan": -0.125, "vis": true }, { - "t": 19.933210668544433, + "t": 19.433210668544433, "z": 4, "pan": -0.125, "vis": true }, { - "t": 20.151822120070452, + "t": 19.651822120070452, "z": 4, "pan": -0.125, "vis": true }, { - "t": 20.13695004049185, + "t": 19.63695004049185, "z": 4, "pan": -0.125, "vis": false }, { - "t": 20.286625445179705, + "t": 19.786625445179705, "z": 4, "pan": -0.125, "vis": false }, { - "t": 20.317589824385525, + "t": 19.817589824385525, "z": 4, "pan": -0.125, "vis": false }, { - "t": 20.23471573876286, + "t": 19.73471573876286, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.282958517925504, + "t": 19.782958517925504, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.29129856337057, + "t": 19.79129856337057, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.050194586739774, + "t": 19.550194586739774, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.169462162225336, + "t": 19.669462162225336, "z": 4, "pan": 0.125, "vis": true }, { - "t": 19.917238811661438, + "t": 19.417238811661438, "z": 4, "pan": 0.125, "vis": true }, { - "t": 19.588972883995897, + "t": 19.088972883995897, "z": 4, "pan": 0.125, "vis": true }, { - "t": 19.43451950988791, + "t": 18.93451950988791, "z": 4, "pan": 0.125, "vis": true }, { - "t": 19.517577056717244, + "t": 19.017577056717244, "z": 4, "pan": 0.125, "vis": true }, { - "t": 19.53814552448372, + "t": 19.03814552448372, "z": 4, "pan": 0.125, "vis": true }, { - "t": 19.935009994614294, + "t": 19.435009994614294, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.053348438837947, + "t": 19.553348438837947, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.10019595700675, + "t": 19.60019595700675, "z": 4, "pan": 0.125, "vis": true }, { - "t": 20.409898476106704, + "t": 19.909898476106704, "z": 4, "pan": 0.125, "vis": false }, { - "t": 20.267673524320852, + "t": 19.767673524320852, "z": 4, "pan": 0.125, "vis": false }, { - "t": 20.385545838819255, + "t": 19.885545838819255, "z": 4, "pan": 0.125, "vis": false }, { - "t": 20.3344152777116, + "t": 19.8344152777116, "z": 4, "pan": 0.375, "vis": true }, { - "t": 20.1974865709402, + "t": 19.6974865709402, "z": 4, "pan": 0.375, "vis": true }, { - "t": 20.42727414238933, + "t": 19.92727414238933, "z": 4, "pan": 0.375, "vis": true }, { - "t": 20.25371246661122, + "t": 19.75371246661122, "z": 4, "pan": 0.375, "vis": true }, { - "t": 20.189428978837096, + "t": 19.689428978837096, "z": 4, "pan": 0.375, "vis": true }, { - "t": 19.87110704195918, + "t": 19.37110704195918, "z": 4, "pan": 0.375, "vis": true }, { - "t": 19.919012020924285, + "t": 19.419012020924285, "z": 4, "pan": 0.375, "vis": true }, { - "t": 19.66140115464659, + "t": 19.16140115464659, "z": 4, "pan": 0.375, "vis": true }, { - "t": 19.628071772849577, + "t": 19.128071772849577, "z": 4, "pan": 0.375, "vis": true }, { - "t": 19.819023875525644, + "t": 19.319023875525644, "z": 4, "pan": 0.375, "vis": true }, { - "t": 19.901092828326796, + "t": 19.401092828326796, "z": 4, "pan": 0.375, "vis": true }, { - "t": 20.152756001611777, + "t": 19.652756001611777, "z": 4, "pan": 0.375, "vis": true }, { - "t": 20.150380725774415, + "t": 19.650380725774415, "z": 4, "pan": 0.375, "vis": false }, { - "t": 20.153916333327548, + "t": 19.653916333327548, "z": 4, "pan": 0.375, "vis": false }, { - "t": 20.457469998282214, + "t": 19.957469998282214, "z": 4, "pan": 0.375, "vis": false }, { - "t": 20.227739941458225, + "t": 19.727739941458225, "z": 4, "pan": 0.375, "vis": false }, { - "t": 20.420297995696323, + "t": 19.920297995696323, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.506935245172492, + "t": 20.006935245172492, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.46932740008255, + "t": 19.96932740008255, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.1531202605363, + "t": 19.6531202605363, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.32387123005442, + "t": 19.82387123005442, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.060798122646027, + "t": 19.560798122646027, "z": 4, "pan": 0.625, "vis": true }, { - "t": 19.97224476053462, + "t": 19.47224476053462, "z": 4, "pan": 0.625, "vis": true }, { - "t": 19.96880708629648, + "t": 19.46880708629648, "z": 4, "pan": 0.625, "vis": true }, { - "t": 19.92258510639837, + "t": 19.42258510639837, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.133578820845404, + "t": 19.633578820845404, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.271098087547088, + "t": 19.771098087547088, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.14172723517672, + "t": 19.64172723517672, "z": 4, "pan": 0.625, "vis": true }, { - "t": 20.17853230583783, + "t": 19.67853230583783, "z": 4, "pan": 0.625, "vis": false }, { - "t": 20.24370535000293, + "t": 19.74370535000293, "z": 4, "pan": 0.625, "vis": false }, { - "t": 20.48886923529993, + "t": 19.98886923529993, "z": 4, "pan": 0.625, "vis": false }, { - "t": 20.30978802603518, + "t": 19.80978802603518, "z": 4, "pan": 0.625, "vis": false }, { - "t": 20.338883880018653, + "t": 19.838883880018653, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.493753543614755, + "t": 19.993753543614755, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.310142185323997, + "t": 19.810142185323997, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.456807925771514, + "t": 19.956807925771514, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.38401425943972, + "t": 19.88401425943972, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.270199726112853, + "t": 19.770199726112853, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.4247340733134, + "t": 19.9247340733134, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.140970173704897, + "t": 19.640970173704897, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.14417596868028, + "t": 19.64417596868028, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.43435145824168, + "t": 19.93435145824168, "z": 4, "pan": 0.875, "vis": true }, { - "t": 20.230710006939052, + "t": 19.730710006939052, "z": 4, "pan": 0.875, "vis": false }, { - "t": 20.350936130220234, + "t": 19.850936130220234, "z": 4, "pan": 0.875, "vis": false }, { - "t": 20.430141386499216, + "t": 19.930141386499216, "z": 4, "pan": 0.875, "vis": false }, { - "t": 20.534368541955057, + "t": 20.034368541955057, "z": 4, "pan": 0.875, "vis": false }, { - "t": 20.424391490198218, + "t": 19.924391490198218, "z": 4, "pan": 0.875, "vis": false }, { - "t": 20.57593341655391, + "t": 20.07593341655391, "z": 4, "pan": 0.875, "vis": false }, { - "t": 20.32240043408858, + "t": 19.82240043408858, "z": 4, "pan": 1, "vis": false }, { - "t": 20.587723195024843, + "t": 20.087723195024843, "z": 4, "pan": 1, "vis": false }, { - "t": 20.625462444980812, + "t": 20.125462444980812, "z": 4, "pan": 1, "vis": false }, { - "t": 20.51780556554647, + "t": 20.01780556554647, "z": 4, "pan": 1, "vis": false }, { - "t": 20.571638517647028, + "t": 20.071638517647028, "z": 4, "pan": 1, "vis": false }, { - "t": 20.700427803877236, + "t": 20.200427803877236, "z": 4, "pan": 1, "vis": false }, { - "t": 20.58807274084886, + "t": 20.08807274084886, "z": 4, "pan": 1, "vis": false }, { - "t": 20.713722787462363, + "t": 20.213722787462363, "z": 4, "pan": 1, "vis": false }, { - "t": 20.586338606625727, + "t": 20.086338606625727, "z": 4, "pan": 1, "vis": false }, { - "t": 20.50592019833804, + "t": 20.00592019833804, "z": 4, "pan": 1, "vis": false }, { - "t": 20.547887283947265, + "t": 20.047887283947265, "z": 4, "pan": 1, "vis": false }, { - "t": 20.464329636044877, + "t": 19.964329636044877, "z": 4, "pan": 1, "vis": false }, { - "t": 20.45572832225591, + "t": 19.95572832225591, "z": 4, "pan": 1, "vis": false }, { - "t": 20.492997224282053, + "t": 19.992997224282053, "z": 4, "pan": 1, "vis": false }, { - "t": 20.500489612655542, + "t": 20.000489612655542, "z": 4, "pan": 1, "vis": false }, { - "t": 20.580398490045464, + "t": 20.080398490045464, "z": 4, "pan": 1, "vis": false }, { - "t": 20.578552089972565, + "t": 20.078552089972565, "z": 4, "pan": 1, "vis": false }, { - "t": 20.456647637793655, + "t": 19.956647637793655, "z": 4, "pan": 1, "vis": false }, { - "t": 20.51760978608596, + "t": 20.01760978608596, "z": 4, "pan": 1, "vis": false }, { - "t": 20.53239622114092, + "t": 20.03239622114092, "z": 4, "pan": 1, "vis": false }, { - "t": 20.8143499943219, + "t": 20.3143499943219, "z": 4, "pan": 1, "vis": false }, { - "t": 20.688749350337222, + "t": 20.188749350337222, "z": 4, "pan": 1, "vis": false }, { - "t": 20.991557378850846, + "t": 20.491557378850846, "z": 4, "pan": 1, "vis": false }, { - "t": 21.035671741186924, + "t": 20.535671741186924, "z": 4, "pan": 1, "vis": false }, { - "t": 20.881191915076887, + "t": 20.381191915076887, "z": 4, "pan": 1, "vis": false }, { - "t": 20.828117900527896, + "t": 20.328117900527896, "z": 4, "pan": 1, "vis": false }, { - "t": 20.891257604754486, + "t": 20.391257604754486, "z": 4, "pan": 1, "vis": false }, { - "t": 20.707898596531095, + "t": 20.207898596531095, "z": 4, "pan": 1, "vis": false }, { - "t": 20.71698517111149, + "t": 20.21698517111149, "z": 4, "pan": 1, "vis": false }, { - "t": 20.468146468815902, + "t": 19.968146468815902, "z": 4, "pan": 1, "vis": false }, { - "t": 20.39822466831335, + "t": 19.89822466831335, "z": 4, "pan": 1, "vis": false }, { - "t": 20.51116946828528, + "t": 20.01116946828528, "z": 4, "pan": 1, "vis": false }, { - "t": 20.626647230000284, + "t": 20.126647230000284, "z": 4, "pan": 1, "vis": false }, { - "t": 20.528237182793273, + "t": 20.028237182793273, "z": 4, "pan": 1, "vis": false }, { - "t": 20.697046324497492, + "t": 20.197046324497492, "z": 4, "pan": 1, "vis": false }, { - "t": 20.61445565088469, + "t": 20.11445565088469, "z": 4, "pan": 1, "vis": false }, { - "t": 20.904247245917926, + "t": 20.404247245917926, "z": 4, "pan": 1, "vis": false }, { - "t": 20.88201275718759, + "t": 20.38201275718759, "z": 4, "pan": 1, "vis": false }, { - "t": 21.18112585198909, + "t": 20.68112585198909, "z": 4, "pan": 1, "vis": false }, { - "t": 21.225091686762656, + "t": 20.725091686762656, "z": 4, "pan": 1, "vis": false }, { - "t": 21.378711504188328, + "t": 20.878711504188328, "z": 4, "pan": 1, "vis": false }, { - "t": 21.04198530426783, + "t": 20.54198530426783, "z": 4, "pan": 1, "vis": false }, { - "t": 20.80308077537413, + "t": 20.30308077537413, "z": 4, "pan": 1, "vis": false }, { - "t": 20.832554898957717, + "t": 20.332554898957717, "z": 4, "pan": 1, "vis": false }, { - "t": 20.550002938773503, + "t": 20.050002938773503, "z": 4, "pan": 1, "vis": false }, { - "t": 20.69280217829802, + "t": 20.19280217829802, "z": 4, "pan": 1, "vis": false }, { - "t": 20.531232671447054, + "t": 20.031232671447054, "z": 4, "pan": 1, "vis": false }, { - "t": 20.636882353506422, + "t": 20.136882353506422, "z": 4, "pan": 1, "vis": false }, { - "t": 20.452277037968845, + "t": 19.952277037968845, "z": 4, "pan": 1, "vis": false }, { - "t": 20.44028941047949, + "t": 19.94028941047949, "z": 4, "pan": 1, "vis": false }, { - "t": 20.502139983839893, + "t": 20.002139983839893, "z": 4, "pan": 1, "vis": false }, { - "t": 20.70443534841505, + "t": 20.20443534841505, "z": 4, "pan": 1, "vis": false }, { - "t": 20.78226978584133, + "t": 20.28226978584133, "z": 4, "pan": 1, "vis": false }, { - "t": 20.96553061645101, + "t": 20.46553061645101, "z": 4, "pan": 1, "vis": false }, { - "t": 21.25815835521531, + "t": 20.75815835521531, "z": 4, "pan": 1, "vis": false }, { - "t": 21.4705606034716, + "t": 20.9705606034716, "z": 4, "pan": 1, "vis": false }, { - "t": 21.479387397921816, + "t": 20.979387397921816, "z": 4, "pan": 1, "vis": false }, { - "t": 21.28463873856357, + "t": 20.78463873856357, "z": 4, "pan": 1, "vis": false }, { - "t": 20.94828242967634, + "t": 20.44828242967634, "z": 4, "pan": 1, "vis": false }, { - "t": 20.782675187970362, + "t": 20.282675187970362, "z": 4, "pan": 1, "vis": false }, { - "t": 20.722494339433737, + "t": 20.222494339433737, "z": 4, "pan": 1, "vis": false }, { - "t": 20.77647040474623, + "t": 20.27647040474623, "z": 4, "pan": 1, "vis": false }, { - "t": 20.43227342028844, + "t": 19.93227342028844, "z": 4, "pan": 1, "vis": false }, { - "t": 20.46191463667781, + "t": 19.96191463667781, "z": 4, "pan": 1, "vis": false }, { - "t": 22.409585883269962, + "t": 21.409585883269962, "z": 5, "pan": -1, "vis": false }, { - "t": 22.394344663490525, + "t": 21.394344663490525, "z": 5, "pan": -1, "vis": false }, { - "t": 22.325239474697355, + "t": 21.325239474697355, "z": 5, "pan": -1, "vis": false }, { - "t": 22.367929038510745, + "t": 21.367929038510745, "z": 5, "pan": -1, "vis": false }, { - "t": 22.37870420183137, + "t": 21.37870420183137, "z": 5, "pan": -1, "vis": false }, { - "t": 22.338395189490825, + "t": 21.338395189490825, "z": 5, "pan": -1, "vis": false }, { - "t": 22.510796843216518, + "t": 21.510796843216518, "z": 5, "pan": -1, "vis": false }, { - "t": 22.557449390721477, + "t": 21.557449390721477, "z": 5, "pan": -1, "vis": false }, { - "t": 22.557324815944604, + "t": 21.557324815944604, "z": 5, "pan": -1, "vis": false }, { - "t": 22.762076224895818, + "t": 21.762076224895818, "z": 5, "pan": -1, "vis": false }, { - "t": 22.718559632429884, + "t": 21.718559632429884, "z": 5, "pan": -1, "vis": false }, { - "t": 22.77244626116346, + "t": 21.77244626116346, "z": 5, "pan": -1, "vis": false }, { - "t": 23.034190917579583, + "t": 22.034190917579583, "z": 5, "pan": -1, "vis": false }, { - "t": 23.145487552869, + "t": 22.145487552869, "z": 5, "pan": -1, "vis": false }, { - "t": 23.25135378010516, + "t": 22.25135378010516, "z": 5, "pan": -1, "vis": false }, { - "t": 23.29298672330349, + "t": 22.29298672330349, "z": 5, "pan": -1, "vis": false }, { - "t": 23.37910926616505, + "t": 22.37910926616505, "z": 5, "pan": -1, "vis": false }, { - "t": 23.213693403115, + "t": 22.213693403115, "z": 5, "pan": -1, "vis": false }, { - "t": 23.23404425602711, + "t": 22.23404425602711, "z": 5, "pan": -1, "vis": false }, { - "t": 23.044992706461052, + "t": 22.044992706461052, "z": 5, "pan": -1, "vis": false }, { - "t": 22.90946513019305, + "t": 21.90946513019305, "z": 5, "pan": -1, "vis": false }, { - "t": 22.731795581607738, + "t": 21.731795581607738, "z": 5, "pan": -1, "vis": false }, { - "t": 22.697557259796895, + "t": 21.697557259796895, "z": 5, "pan": -1, "vis": false }, { - "t": 22.61902293099393, + "t": 21.61902293099393, "z": 5, "pan": -1, "vis": false }, { - "t": 22.49536458591876, + "t": 21.49536458591876, "z": 5, "pan": -1, "vis": false }, { - "t": 22.35843103841501, + "t": 21.35843103841501, "z": 5, "pan": -1, "vis": false }, { - "t": 22.324772550559317, + "t": 21.324772550559317, "z": 5, "pan": -1, "vis": false }, { - "t": 22.491298643048353, + "t": 21.491298643048353, "z": 5, "pan": -1, "vis": false }, { - "t": 22.487880783670143, + "t": 21.487880783670143, "z": 5, "pan": -1, "vis": false }, { - "t": 22.23629608178578, + "t": 21.23629608178578, "z": 5, "pan": -1, "vis": false }, { - "t": 22.431618350726875, + "t": 21.431618350726875, "z": 5, "pan": -1, "vis": false }, { - "t": 22.32307665065479, + "t": 21.32307665065479, "z": 5, "pan": -1, "vis": false }, { - "t": 22.28245264150435, + "t": 21.28245264150435, "z": 5, "pan": -1, "vis": false }, { - "t": 22.206360150859783, + "t": 21.206360150859783, "z": 5, "pan": -1, "vis": false }, { - "t": 22.39745623345171, + "t": 21.39745623345171, "z": 5, "pan": -1, "vis": false }, { - "t": 22.280934698819248, + "t": 21.280934698819248, "z": 5, "pan": -1, "vis": false }, { - "t": 22.288799389659466, + "t": 21.288799389659466, "z": 5, "pan": -1, "vis": false }, { - "t": 22.31649850438139, + "t": 21.31649850438139, "z": 5, "pan": -1, "vis": false }, { - "t": 22.351798913952464, + "t": 21.351798913952464, "z": 5, "pan": -1, "vis": false }, { - "t": 22.452936591862976, + "t": 21.452936591862976, "z": 5, "pan": -1, "vis": false }, { - "t": 22.577885253134625, + "t": 21.577885253134625, "z": 5, "pan": -1, "vis": false }, { - "t": 22.7280669031909, + "t": 21.7280669031909, "z": 5, "pan": -1, "vis": false }, { - "t": 22.733379088990706, + "t": 21.733379088990706, "z": 5, "pan": -1, "vis": false }, { - "t": 22.716873600210945, + "t": 21.716873600210945, "z": 5, "pan": -1, "vis": false }, { - "t": 22.97621119804002, + "t": 21.97621119804002, "z": 5, "pan": -1, "vis": false }, { - "t": 23.14808450368325, + "t": 22.14808450368325, "z": 5, "pan": -1, "vis": false }, { - "t": 23.219534103966268, + "t": 22.219534103966268, "z": 5, "pan": -1, "vis": false }, { - "t": 23.272046347760188, + "t": 22.272046347760188, "z": 5, "pan": -1, "vis": false }, { - "t": 23.20791228787214, + "t": 22.20791228787214, "z": 5, "pan": -1, "vis": false }, { - "t": 23.151245095046566, + "t": 22.151245095046566, "z": 5, "pan": -1, "vis": false }, { - "t": 23.075640545731897, + "t": 22.075640545731897, "z": 5, "pan": -1, "vis": false }, { - "t": 23.025499120312574, + "t": 22.025499120312574, "z": 5, "pan": -1, "vis": false }, { - "t": 22.762006573451842, + "t": 21.762006573451842, "z": 5, "pan": -1, "vis": false }, { - "t": 22.77435711319995, + "t": 21.77435711319995, "z": 5, "pan": -1, "vis": false }, { - "t": 22.640776807624047, + "t": 21.640776807624047, "z": 5, "pan": -1, "vis": false }, { - "t": 22.48644020853612, + "t": 21.48644020853612, "z": 5, "pan": -1, "vis": false }, { - "t": 22.60733659823281, + "t": 21.60733659823281, "z": 5, "pan": -1, "vis": false }, { - "t": 22.391357332232978, + "t": 21.391357332232978, "z": 5, "pan": -1, "vis": false }, { - "t": 22.33847544194347, + "t": 21.33847544194347, "z": 5, "pan": -1, "vis": false }, { - "t": 22.3066213781899, + "t": 21.3066213781899, "z": 5, "pan": -1, "vis": false }, { - "t": 22.246031621261412, + "t": 21.246031621261412, "z": 5, "pan": -1, "vis": false }, { - "t": 22.282855153174395, + "t": 21.282855153174395, "z": 5, "pan": -1, "vis": false }, { - "t": 22.337604121550804, + "t": 21.337604121550804, "z": 5, "pan": -1, "vis": false }, { - "t": 22.409541663163726, + "t": 21.409541663163726, "z": 5, "pan": -1, "vis": false }, { - "t": 22.226033675250203, + "t": 21.226033675250203, "z": 5, "pan": -1, "vis": false }, { - "t": 22.400284150441045, + "t": 21.400284150441045, "z": 5, "pan": -1, "vis": false }, { - "t": 22.299931149082497, + "t": 21.299931149082497, "z": 5, "pan": -1, "vis": false }, { - "t": 22.429782367448404, + "t": 21.429782367448404, "z": 5, "pan": -1, "vis": false }, { - "t": 22.42510703370462, + "t": 21.42510703370462, "z": 5, "pan": -1, "vis": false }, { - "t": 22.398221132492914, + "t": 21.398221132492914, "z": 5, "pan": -1, "vis": false }, { - "t": 22.486739858386684, + "t": 21.486739858386684, "z": 5, "pan": -1, "vis": false }, { - "t": 22.56144842275553, + "t": 21.56144842275553, "z": 5, "pan": -1, "vis": false }, { - "t": 22.61734377265104, + "t": 21.61734377265104, "z": 5, "pan": -1, "vis": false }, { - "t": 22.65547673426912, + "t": 21.65547673426912, "z": 5, "pan": -1, "vis": false }, { - "t": 22.588183342183658, + "t": 21.588183342183658, "z": 5, "pan": -1, "vis": false }, { - "t": 22.734344508971603, + "t": 21.734344508971603, "z": 5, "pan": -1, "vis": false }, { - "t": 22.8605155589679, + "t": 21.8605155589679, "z": 5, "pan": -1, "vis": false }, { - "t": 23.00465694768112, + "t": 22.00465694768112, "z": 5, "pan": -1, "vis": false }, { - "t": 22.95852505976148, + "t": 21.95852505976148, "z": 5, "pan": -1, "vis": false }, { - "t": 23.102436329455657, + "t": 22.102436329455657, "z": 5, "pan": -1, "vis": false }, { - "t": 23.09176224162006, + "t": 22.09176224162006, "z": 5, "pan": -1, "vis": false }, { - "t": 22.96609707182242, + "t": 21.96609707182242, "z": 5, "pan": -1, "vis": false }, { - "t": 23.030475059638615, + "t": 22.030475059638615, "z": 5, "pan": -1, "vis": false }, { - "t": 22.864985495254274, + "t": 21.864985495254274, "z": 5, "pan": -1, "vis": false }, { - "t": 22.75706054515445, + "t": 21.75706054515445, "z": 5, "pan": -1, "vis": false }, { - "t": 22.62914547826304, + "t": 21.62914547826304, "z": 5, "pan": -1, "vis": false }, { - "t": 22.675090694677355, + "t": 21.675090694677355, "z": 5, "pan": -1, "vis": false }, { - "t": 22.6552038329558, + "t": 21.6552038329558, "z": 5, "pan": -1, "vis": false }, { - "t": 22.6175545829567, + "t": 21.6175545829567, "z": 5, "pan": -1, "vis": false }, { - "t": 22.38493838875076, + "t": 21.38493838875076, "z": 5, "pan": -1, "vis": false }, { - "t": 22.451225216919383, + "t": 21.451225216919383, "z": 5, "pan": -1, "vis": false }, { - "t": 22.496357218027686, + "t": 21.496357218027686, "z": 5, "pan": -1, "vis": false }, { - "t": 22.22554825909559, + "t": 21.22554825909559, "z": 5, "pan": -1, "vis": false }, { - "t": 22.36807925763095, + "t": 21.36807925763095, "z": 5, "pan": -1, "vis": false }, { - "t": 22.23667835888589, + "t": 21.23667835888589, "z": 5, "pan": -1, "vis": false }, { - "t": 22.330673983591662, + "t": 21.330673983591662, "z": 5, "pan": -1, "vis": false }, { - "t": 22.210956712642506, + "t": 21.210956712642506, "z": 5, "pan": -1, "vis": false }, { - "t": 22.343464218576738, + "t": 21.343464218576738, "z": 5, "pan": -1, "vis": false }, { - "t": 22.409451698389034, + "t": 21.409451698389034, "z": 5, "pan": -1, "vis": false }, { - "t": 22.35602060838516, + "t": 21.35602060838516, "z": 5, "pan": -1, "vis": false }, { - "t": 22.296262414487135, + "t": 21.296262414487135, "z": 5, "pan": -1, "vis": false }, { - "t": 22.422030565608154, + "t": 21.422030565608154, "z": 5, "pan": -1, "vis": false }, { - "t": 22.383453339717686, + "t": 21.383453339717686, "z": 5, "pan": -1, "vis": false }, { - "t": 22.3884091093098, + "t": 21.3884091093098, "z": 5, "pan": -1, "vis": false }, { - "t": 22.581584106370205, + "t": 21.581584106370205, "z": 5, "pan": -1, "vis": false }, { - "t": 22.46364040336255, + "t": 21.46364040336255, "z": 5, "pan": -1, "vis": false }, { - "t": 22.642111578178575, + "t": 21.642111578178575, "z": 5, "pan": -1, "vis": false }, { - "t": 22.656544738517127, + "t": 21.656544738517127, "z": 5, "pan": -1, "vis": false }, { - "t": 22.855859210235323, + "t": 21.855859210235323, "z": 5, "pan": -1, "vis": false }, { - "t": 22.793960729565757, + "t": 21.793960729565757, "z": 5, "pan": -1, "vis": false }, { - "t": 22.845604801460706, + "t": 21.845604801460706, "z": 5, "pan": -1, "vis": false }, { - "t": 23.05220019562872, + "t": 22.05220019562872, "z": 5, "pan": -1, "vis": false }, { - "t": 22.916184691418053, + "t": 21.916184691418053, "z": 5, "pan": -1, "vis": false }, { - "t": 22.917221604199366, + "t": 21.917221604199366, "z": 5, "pan": -1, "vis": false }, { - "t": 22.823209839253703, + "t": 21.823209839253703, "z": 5, "pan": -1, "vis": false }, { - "t": 22.863077311502135, + "t": 21.863077311502135, "z": 5, "pan": -1, "vis": false }, { - "t": 22.62139514673308, + "t": 21.62139514673308, "z": 5, "pan": -1, "vis": false }, { - "t": 22.5645942933438, + "t": 21.5645942933438, "z": 5, "pan": -1, "vis": false }, { - "t": 22.614092110106537, + "t": 21.614092110106537, "z": 5, "pan": -1, "vis": false }, { - "t": 22.43966812006349, + "t": 21.43966812006349, "z": 5, "pan": -1, "vis": false }, { - "t": 22.454125429952082, + "t": 21.454125429952082, "z": 5, "pan": -1, "vis": false }, { - "t": 22.52788717940987, + "t": 21.52788717940987, "z": 5, "pan": -1, "vis": false }, { - "t": 22.42334818477825, + "t": 21.42334818477825, "z": 5, "pan": -1, "vis": false }, { - "t": 22.255212340606672, + "t": 21.255212340606672, "z": 5, "pan": -1, "vis": false }, { - "t": 22.34641820587595, + "t": 21.34641820587595, "z": 5, "pan": -1, "vis": false }, { - "t": 22.304002923036634, + "t": 21.304002923036634, "z": 5, "pan": -1, "vis": false }, { - "t": 22.19564775017332, + "t": 21.19564775017332, "z": 5, "pan": -1, "vis": false }, { - "t": 22.270772551188518, + "t": 21.270772551188518, "z": 5, "pan": -1, "vis": false }, { - "t": 22.23755736476289, + "t": 21.23755736476289, "z": 5, "pan": -1, "vis": false }, { - "t": 22.28631190923107, + "t": 21.28631190923107, "z": 5, "pan": -1, "vis": false }, { - "t": 22.226524360476834, + "t": 21.226524360476834, "z": 5, "pan": -1, "vis": false }, { - "t": 22.310275926743113, + "t": 21.310275926743113, "z": 5, "pan": -1, "vis": false }, { - "t": 22.40304619603832, + "t": 21.40304619603832, "z": 5, "pan": -1, "vis": false }, { - "t": 22.388909573417546, + "t": 21.388909573417546, "z": 5, "pan": -1, "vis": false }, { - "t": 22.293193967036544, + "t": 21.293193967036544, "z": 5, "pan": -1, "vis": false }, { - "t": 22.43544996624245, + "t": 21.43544996624245, "z": 5, "pan": -1, "vis": false }, { - "t": 22.472776273272572, + "t": 21.472776273272572, "z": 5, "pan": -1, "vis": false }, { - "t": 22.405520102946937, + "t": 21.405520102946937, "z": 5, "pan": -1, "vis": false }, { - "t": 22.64930841874347, + "t": 21.64930841874347, "z": 5, "pan": -1, "vis": false }, { - "t": 22.48935340788403, + "t": 21.48935340788403, "z": 5, "pan": -1, "vis": false }, { - "t": 22.720420834377585, + "t": 21.720420834377585, "z": 5, "pan": -1, "vis": false }, { - "t": 22.776210397195456, + "t": 21.776210397195456, "z": 5, "pan": -1, "vis": false }, { - "t": 22.889654422883797, + "t": 21.889654422883797, "z": 5, "pan": -1, "vis": false }, { - "t": 22.873212313389494, + "t": 21.873212313389494, "z": 5, "pan": -1, "vis": false }, { - "t": 22.68305400437604, + "t": 21.68305400437604, "z": 5, "pan": -1, "vis": false }, { - "t": 22.76349978599716, + "t": 21.76349978599716, "z": 5, "pan": -1, "vis": false }, { - "t": 22.714059432435604, + "t": 21.714059432435604, "z": 5, "pan": -1, "vis": false }, { - "t": 22.777953251591228, + "t": 21.777953251591228, "z": 5, "pan": -1, "vis": false }, { - "t": 22.610889497224196, + "t": 21.610889497224196, "z": 5, "pan": -1, "vis": false }, { - "t": 22.584848180210425, + "t": 21.584848180210425, "z": 5, "pan": -1, "vis": false }, { - "t": 22.460743246387167, + "t": 21.460743246387167, "z": 5, "pan": -1, "vis": false }, { - "t": 22.342003088839547, + "t": 21.342003088839547, "z": 5, "pan": -1, "vis": false }, { - "t": 22.368680453935745, + "t": 21.368680453935745, "z": 5, "pan": -1, "vis": false }, { - "t": 22.32145831330138, + "t": 21.32145831330138, "z": 5, "pan": -1, "vis": false }, { - "t": 22.255827115210504, + "t": 21.255827115210504, "z": 5, "pan": -1, "vis": false }, { - "t": 22.33396740995828, + "t": 21.33396740995828, "z": 5, "pan": -1, "vis": false }, { - "t": 22.35926796671609, + "t": 21.35926796671609, "z": 5, "pan": -1, "vis": false }, { - "t": 22.341132628496883, + "t": 21.341132628496883, "z": 5, "pan": -1, "vis": false }, { - "t": 22.214923849377353, + "t": 21.214923849377353, "z": 5, "pan": -1, "vis": false }, { - "t": 22.23017297703631, + "t": 21.23017297703631, "z": 5, "pan": -1, "vis": false }, { - "t": 22.327888665788358, + "t": 21.327888665788358, "z": 5, "pan": -1, "vis": false }, { - "t": 22.193359545756294, + "t": 21.193359545756294, "z": 5, "pan": -1, "vis": false }, { - "t": 22.270888850434744, + "t": 21.270888850434744, "z": 5, "pan": -1, "vis": false }, { - "t": 22.381374462625644, + "t": 21.381374462625644, "z": 5, "pan": -1, "vis": false }, { - "t": 22.347157285836953, + "t": 21.347157285836953, "z": 5, "pan": -1, "vis": false }, { - "t": 22.276183990120817, + "t": 21.276183990120817, "z": 5, "pan": -1, "vis": false }, { - "t": 22.227001520919504, + "t": 21.227001520919504, "z": 5, "pan": -1, "vis": false }, { - "t": 22.29699137209787, + "t": 21.29699137209787, "z": 5, "pan": -1, "vis": false }, { - "t": 22.331547045506348, + "t": 21.331547045506348, "z": 5, "pan": -1, "vis": false }, { - "t": 22.330811348827773, + "t": 21.330811348827773, "z": 5, "pan": -1, "vis": false }, { - "t": 22.553099126161406, + "t": 21.553099126161406, "z": 5, "pan": -1, "vis": false }, { - "t": 22.443066170878048, + "t": 21.443066170878048, "z": 5, "pan": -1, "vis": false }, { - "t": 22.54400149301462, + "t": 21.54400149301462, "z": 5, "pan": -1, "vis": false }, { - "t": 22.598338605016647, + "t": 21.598338605016647, "z": 5, "pan": -1, "vis": false }, { - "t": 22.571914599435413, + "t": 21.571914599435413, "z": 5, "pan": -1, "vis": false }, { - "t": 22.742825913702248, + "t": 21.742825913702248, "z": 5, "pan": -1, "vis": false }, { - "t": 22.591473166568512, + "t": 21.591473166568512, "z": 5, "pan": -1, "vis": false }, { - "t": 22.61357188810788, + "t": 21.61357188810788, "z": 5, "pan": -1, "vis": false }, { - "t": 22.583005929495318, + "t": 21.583005929495318, "z": 5, "pan": -1, "vis": false }, { - "t": 22.4759633232259, + "t": 21.4759633232259, "z": 5, "pan": -1, "vis": false }, { - "t": 22.568038036895448, + "t": 21.568038036895448, "z": 5, "pan": -1, "vis": false }, { - "t": 22.371081027985014, + "t": 21.371081027985014, "z": 5, "pan": -1, "vis": false }, { - "t": 22.34608775638392, + "t": 21.34608775638392, "z": 5, "pan": -1, "vis": false }, { - "t": 22.289833488868695, + "t": 21.289833488868695, "z": 5, "pan": -1, "vis": false }, { - "t": 22.44828785126632, + "t": 21.44828785126632, "z": 5, "pan": -1, "vis": false }, { - "t": 22.26752263162794, + "t": 21.26752263162794, "z": 5, "pan": -1, "vis": false }, { - "t": 22.317785010827947, + "t": 21.317785010827947, "z": 5, "pan": -1, "vis": false }, { - "t": 22.33176834235033, + "t": 21.33176834235033, "z": 5, "pan": -1, "vis": false }, { - "t": 22.24272533622034, + "t": 21.24272533622034, "z": 5, "pan": -1, "vis": false }, { - "t": 22.395804448486878, + "t": 21.395804448486878, "z": 5, "pan": -1, "vis": false }, { - "t": 22.261285179614507, + "t": 21.261285179614507, "z": 5, "pan": -1, "vis": false }, { - "t": 22.33882433545282, + "t": 21.33882433545282, "z": 5, "pan": -1, "vis": false }, { - "t": 22.34294997165441, + "t": 21.34294997165441, "z": 5, "pan": -1, "vis": false }, { - "t": 22.15364976218186, + "t": 21.15364976218186, "z": 5, "pan": -1, "vis": false }, { - "t": 22.134242579479444, + "t": 21.134242579479444, "z": 5, "pan": -1, "vis": false }, { - "t": 22.368184118058206, + "t": 21.368184118058206, "z": 5, "pan": -1, "vis": false }, { - "t": 22.26498074091589, + "t": 21.26498074091589, "z": 5, "pan": -1, "vis": false }, { - "t": 22.332386725194475, + "t": 21.332386725194475, "z": 5, "pan": -1, "vis": false }, { - "t": 22.21175230594658, + "t": 21.21175230594658, "z": 5, "pan": -1, "vis": false }, { - "t": 22.19696735449107, + "t": 21.19696735449107, "z": 5, "pan": -1, "vis": false }, { - "t": 22.353540980550605, + "t": 21.353540980550605, "z": 5, "pan": -1, "vis": false }, { - "t": 22.431513444078686, + "t": 21.431513444078686, "z": 5, "pan": -1, "vis": false }, { - "t": 22.455273450486853, + "t": 21.455273450486853, "z": 5, "pan": -1, "vis": false }, { - "t": 22.450527104312407, + "t": 21.450527104312407, "z": 5, "pan": -1, "vis": false }, { - "t": 22.364727330360743, + "t": 21.364727330360743, "z": 5, "pan": -1, "vis": false }, { - "t": 22.528139516851645, + "t": 21.528139516851645, "z": 5, "pan": -1, "vis": false }, { - "t": 22.348809044056974, + "t": 21.348809044056974, "z": 5, "pan": -1, "vis": false }, { - "t": 22.580748761148538, + "t": 21.580748761148538, "z": 5, "pan": -1, "vis": false }, { - "t": 22.36254518249702, + "t": 21.36254518249702, "z": 5, "pan": -1, "vis": false }, { - "t": 22.489369247658455, + "t": 21.489369247658455, "z": 5, "pan": -1, "vis": false }, { - "t": 22.5274635027061, + "t": 21.5274635027061, "z": 5, "pan": -1, "vis": false }, { - "t": 22.427644158912386, + "t": 21.427644158912386, "z": 5, "pan": -1, "vis": false }, { - "t": 22.37220771511685, + "t": 21.37220771511685, "z": 5, "pan": -1, "vis": false }, { - "t": 22.48571784354426, + "t": 21.48571784354426, "z": 5, "pan": -1, "vis": false }, { - "t": 22.275550679833163, + "t": 21.275550679833163, "z": 5, "pan": -1, "vis": false }, { - "t": 22.306341998558054, + "t": 21.306341998558054, "z": 5, "pan": -1, "vis": false }, { - "t": 22.258532154751226, + "t": 21.258532154751226, "z": 5, "pan": -1, "vis": false }, { - "t": 22.19584124118805, + "t": 21.19584124118805, "z": 5, "pan": -1, "vis": false }, { - "t": 22.31630815040451, + "t": 21.31630815040451, "z": 5, "pan": -1, "vis": false }, { - "t": 22.35766594837902, + "t": 21.35766594837902, "z": 5, "pan": -1, "vis": false }, { - "t": 22.191215461204074, + "t": 21.191215461204074, "z": 5, "pan": -1, "vis": false }, { - "t": 22.149284411892612, + "t": 21.149284411892612, "z": 5, "pan": -1, "vis": false }, { - "t": 22.27745537684771, + "t": 21.27745537684771, "z": 5, "pan": -1, "vis": false }, { - "t": 22.32551936857337, + "t": 21.32551936857337, "z": 5, "pan": -1, "vis": false }, { - "t": 22.151153999964876, + "t": 21.151153999964876, "z": 5, "pan": -1, "vis": false }, { - "t": 22.11539129073067, + "t": 21.11539129073067, "z": 5, "pan": -1, "vis": false }, { - "t": 22.207320320403976, + "t": 21.207320320403976, "z": 5, "pan": -1, "vis": false }, { - "t": 22.135655987152912, + "t": 21.135655987152912, "z": 5, "pan": -1, "vis": false }, { - "t": 22.171654785962776, + "t": 21.171654785962776, "z": 5, "pan": -1, "vis": false }, { - "t": 22.3355847374923, + "t": 21.3355847374923, "z": 5, "pan": -1, "vis": false }, { - "t": 22.214312293243715, + "t": 21.214312293243715, "z": 5, "pan": -1, "vis": false }, { - "t": 22.32284277685754, + "t": 21.32284277685754, "z": 5, "pan": -1, "vis": false }, { - "t": 22.30954606528891, + "t": 21.30954606528891, "z": 5, "pan": -1, "vis": false }, { - "t": 22.17442775513799, + "t": 21.17442775513799, "z": 5, "pan": -1, "vis": false }, { - "t": 22.320757612132912, + "t": 21.320757612132912, "z": 5, "pan": -1, "vis": false }, { - "t": 22.380001877517994, + "t": 21.380001877517994, "z": 5, "pan": -1, "vis": false }, { - "t": 22.316575958568688, + "t": 21.316575958568688, "z": 5, "pan": -1, "vis": false }, { - "t": 22.411247844336636, + "t": 21.411247844336636, "z": 5, "pan": -1, "vis": false }, { - "t": 22.288595373534644, + "t": 21.288595373534644, "z": 5, "pan": -1, "vis": false }, { - "t": 22.29079506195926, + "t": 21.29079506195926, "z": 5, "pan": -1, "vis": false }, { - "t": 22.3269392633623, + "t": 21.3269392633623, "z": 5, "pan": -1, "vis": false }, { - "t": 22.26624226176217, + "t": 21.26624226176217, "z": 5, "pan": -1, "vis": false }, { - "t": 22.330397419388646, + "t": 21.330397419388646, "z": 5, "pan": -1, "vis": false }, { - "t": 22.308013936427013, + "t": 21.308013936427013, "z": 5, "pan": -1, "vis": false }, { - "t": 22.312942542200656, + "t": 21.312942542200656, "z": 5, "pan": -1, "vis": false }, { - "t": 22.195200963640023, + "t": 21.195200963640023, "z": 5, "pan": -1, "vis": false }, { - "t": 22.371159509451196, + "t": 21.371159509451196, "z": 5, "pan": -1, "vis": false }, { - "t": 22.197780506426664, + "t": 21.197780506426664, "z": 5, "pan": -1, "vis": false }, { - "t": 22.152579904819348, + "t": 21.152579904819348, "z": 5, "pan": -1, "vis": false }, { - "t": 22.279564250262766, + "t": 21.279564250262766, "z": 5, "pan": -1, "vis": false }, { - "t": 22.17911295508434, + "t": 21.17911295508434, "z": 5, "pan": -1, "vis": false }, { - "t": 22.20668569037959, + "t": 21.20668569037959, "z": 5, "pan": -1, "vis": false }, { - "t": 22.284330536488532, + "t": 21.284330536488532, "z": 5, "pan": -1, "vis": false }, { - "t": 22.256142314451406, + "t": 21.256142314451406, "z": 5, "pan": -1, "vis": false }, { - "t": 22.355715971602137, + "t": 21.355715971602137, "z": 5, "pan": -1, "vis": false }, { - "t": 22.332981367661173, + "t": 21.332981367661173, "z": 5, "pan": -1, "vis": false }, { - "t": 22.268415967139415, + "t": 21.268415967139415, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.107313459632802, + "t": 21.107313459632802, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.144395495539552, + "t": 21.144395495539552, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.121657043256747, + "t": 21.121657043256747, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.088247971435155, + "t": 21.088247971435155, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.252784029214855, + "t": 21.252784029214855, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.19984448144182, + "t": 21.19984448144182, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.293358668545352, + "t": 21.293358668545352, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.08457633315221, + "t": 21.08457633315221, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.073491878662246, + "t": 21.073491878662246, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.220843202245508, + "t": 21.220843202245508, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.138974833627774, + "t": 21.138974833627774, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.255652931665036, + "t": 21.255652931665036, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.1248530199878, + "t": 21.1248530199878, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.17433633128626, + "t": 21.17433633128626, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.174843765678943, + "t": 21.174843765678943, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.022762331878592, + "t": 21.022762331878592, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.209745117704294, + "t": 21.209745117704294, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.097752026624228, + "t": 21.097752026624228, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.17438907070083, + "t": 21.17438907070083, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.245201192881893, + "t": 21.245201192881893, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.264559781718017, + "t": 21.264559781718017, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.063045590534077, + "t": 21.063045590534077, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.26162026524242, + "t": 21.26162026524242, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.15789282085387, + "t": 21.15789282085387, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.175543623550197, + "t": 21.175543623550197, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.25064553414102, + "t": 21.25064553414102, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.273599696579737, + "t": 21.273599696579737, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.260780792122635, + "t": 21.260780792122635, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.213074573301846, + "t": 21.213074573301846, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.11348275761338, + "t": 21.11348275761338, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.212075485338435, + "t": 21.212075485338435, "z": 5, "pan": -0.9375, "vis": false }, { - "t": 22.318411107010416, + "t": 21.318411107010416, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.090537290376876, + "t": 21.090537290376876, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.26864672929405, + "t": 21.26864672929405, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.17759437846628, + "t": 21.17759437846628, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.063078673231598, + "t": 21.063078673231598, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.1038298888969, + "t": 21.1038298888969, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.037544289408057, + "t": 21.037544289408057, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.034065102215667, + "t": 21.034065102215667, "z": 5, "pan": -0.8125, "vis": true }, { - "t": 22.18510361982908, + "t": 21.18510361982908, "z": 5, "pan": -0.8125, "vis": true }, { - "t": 21.9906195822948, + "t": 20.9906195822948, "z": 5, "pan": -0.8125, "vis": true }, { - "t": 21.997832729749653, + "t": 20.997832729749653, "z": 5, "pan": -0.8125, "vis": true }, { - "t": 22.009646163326227, + "t": 21.009646163326227, "z": 5, "pan": -0.8125, "vis": true }, { - "t": 21.928389307000508, + "t": 20.928389307000508, "z": 5, "pan": -0.8125, "vis": true }, { - "t": 21.966141067885133, + "t": 20.966141067885133, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.10199357181097, + "t": 21.10199357181097, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 21.902452168171525, + "t": 20.902452168171525, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 21.992010487710505, + "t": 20.992010487710505, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.021163397409342, + "t": 21.021163397409342, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 21.964922399542854, + "t": 20.964922399542854, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.106287277736364, + "t": 21.106287277736364, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.017155640121306, + "t": 21.017155640121306, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.08495371260412, + "t": 21.08495371260412, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.00685720422728, + "t": 21.00685720422728, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.030952747820912, + "t": 21.030952747820912, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.209525736266652, + "t": 21.209525736266652, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.1541437712544, + "t": 21.1541437712544, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.288018668085098, + "t": 21.288018668085098, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.07687895847926, + "t": 21.07687895847926, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.2954473605909, + "t": 21.2954473605909, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.291175159679124, + "t": 21.291175159679124, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.192677226821036, + "t": 21.192677226821036, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.250162549514084, + "t": 21.250162549514084, "z": 5, "pan": -0.8125, "vis": false }, { - "t": 22.163291961089733, + "t": 21.163291961089733, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.077323403775107, + "t": 21.077323403775107, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.1051727040708, + "t": 21.1051727040708, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.26726547606045, + "t": 21.26726547606045, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.030439919231554, + "t": 21.030439919231554, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.15624676365103, + "t": 21.15624676365103, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.14819841054509, + "t": 21.14819841054509, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.00662076947821, + "t": 21.00662076947821, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.9763535885021, + "t": 20.9763535885021, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.057254059933918, + "t": 21.057254059933918, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.99562863289281, + "t": 20.99562863289281, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 22.05940502433768, + "t": 21.05940502433768, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.988089420683153, + "t": 20.988089420683153, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.76460545248618, + "t": 20.76460545248618, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.944227837957992, + "t": 20.944227837957992, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.752391185902653, + "t": 20.752391185902653, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.904787870353417, + "t": 20.904787870353417, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.73534247243374, + "t": 20.73534247243374, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.944438036986845, + "t": 20.944438036986845, "z": 5, "pan": -0.6875, "vis": true }, { - "t": 21.972715374085602, + "t": 20.972715374085602, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 21.932748927764862, + "t": 20.932748927764862, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.007690486344977, + "t": 21.007690486344977, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.124109282287645, + "t": 21.124109282287645, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 21.931926760880778, + "t": 20.931926760880778, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.100911891881445, + "t": 21.100911891881445, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.04329342937394, + "t": 21.04329342937394, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.19404920498045, + "t": 21.19404920498045, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.206960310586183, + "t": 21.206960310586183, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.227810428082385, + "t": 21.227810428082385, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.225204414352024, + "t": 21.225204414352024, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.08607306408079, + "t": 21.08607306408079, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.31075957142064, + "t": 21.31075957142064, "z": 5, "pan": -0.6875, "vis": false }, { - "t": 22.06265118483371, + "t": 21.06265118483371, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 22.160380132733334, + "t": 21.160380132733334, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 22.19252745617625, + "t": 21.19252745617625, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 22.177291879924326, + "t": 21.177291879924326, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 22.180460046726033, + "t": 21.180460046726033, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 22.116411387764604, + "t": 21.116411387764604, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.950833777672212, + "t": 20.950833777672212, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 22.1039711582923, + "t": 21.1039711582923, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.93791451340902, + "t": 20.93791451340902, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.952316628202396, + "t": 20.952316628202396, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.915128268532236, + "t": 20.915128268532236, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.895807885267697, + "t": 20.895807885267697, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.81134134697069, + "t": 20.81134134697069, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.6323425337917, + "t": 20.6323425337917, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.78907805770846, + "t": 20.78907805770846, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.651575999128802, + "t": 20.651575999128802, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.638715851293043, + "t": 20.638715851293043, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.777904563006096, + "t": 20.777904563006096, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.622855692222757, + "t": 20.622855692222757, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.776134209730266, + "t": 20.776134209730266, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.86228740116063, + "t": 20.86228740116063, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.883294437558575, + "t": 20.883294437558575, "z": 5, "pan": -0.5625, "vis": true }, { - "t": 21.894762501557217, + "t": 20.894762501557217, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 21.882047039897238, + "t": 20.882047039897238, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.04979033791387, + "t": 21.04979033791387, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.15456640223613, + "t": 21.15456640223613, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.038196924848016, + "t": 21.038196924848016, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.103932236942875, + "t": 21.103932236942875, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.056376397385076, + "t": 21.056376397385076, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.091966003854996, + "t": 21.091966003854996, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.061505333545394, + "t": 21.061505333545394, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.21546303877922, + "t": 21.21546303877922, "z": 5, "pan": -0.5625, "vis": false }, { - "t": 22.152653608288404, + "t": 21.152653608288404, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 22.172688324750712, + "t": 21.172688324750712, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 22.08511934965574, + "t": 21.08511934965574, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 22.061339572673514, + "t": 21.061339572673514, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.973094932697148, + "t": 20.973094932697148, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 22.025200230022648, + "t": 21.025200230022648, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.906757382439228, + "t": 20.906757382439228, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.953253722701803, + "t": 20.953253722701803, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.88740711781693, + "t": 20.88740711781693, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.95855549532102, + "t": 20.95855549532102, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.840957419630925, + "t": 20.840957419630925, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.653307595197063, + "t": 20.653307595197063, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.606652741704092, + "t": 20.606652741704092, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.6496151150121, + "t": 20.6496151150121, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.62420932275093, + "t": 20.62420932275093, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.51972849053759, + "t": 20.51972849053759, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.55699868089864, + "t": 20.55699868089864, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.531654991480224, + "t": 20.531654991480224, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.677236262109616, + "t": 20.677236262109616, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.70881426952387, + "t": 20.70881426952387, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.62564460138495, + "t": 20.62564460138495, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.93346990418704, + "t": 20.93346990418704, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.875608360599273, + "t": 20.875608360599273, "z": 5, "pan": -0.4375, "vis": true }, { - "t": 21.924635461463442, + "t": 20.924635461463442, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.110657544716332, + "t": 21.110657544716332, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 21.94872286194603, + "t": 20.94872286194603, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.127319911127422, + "t": 21.127319911127422, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.195390092170246, + "t": 21.195390092170246, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.18620208710057, + "t": 21.18620208710057, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.25213036821924, + "t": 21.25213036821924, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.20987482168221, + "t": 21.20987482168221, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.060015583588246, + "t": 21.060015583588246, "z": 5, "pan": -0.4375, "vis": false }, { - "t": 22.039006853667765, + "t": 21.039006853667765, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 22.190342489026882, + "t": 21.190342489026882, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 22.192157883256208, + "t": 21.192157883256208, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.981766164048295, + "t": 20.981766164048295, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 22.011482552071154, + "t": 21.011482552071154, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 22.139285789882834, + "t": 21.139285789882834, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.890417356659242, + "t": 20.890417356659242, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 22.04020479029856, + "t": 21.04020479029856, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.78468172073202, + "t": 20.78468172073202, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.872797321763795, + "t": 20.872797321763795, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.747634179119274, + "t": 20.747634179119274, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.601300236389484, + "t": 20.601300236389484, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.550832692771866, + "t": 20.550832692771866, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.384425562841162, + "t": 20.384425562841162, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.379839618548036, + "t": 20.379839618548036, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.261086798962037, + "t": 20.261086798962037, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.419685939092044, + "t": 20.419685939092044, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.444088220406005, + "t": 20.444088220406005, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.35432362642705, + "t": 20.35432362642705, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.587929036618135, + "t": 20.587929036618135, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.544046041963433, + "t": 20.544046041963433, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.846029446421127, + "t": 20.846029446421127, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.788390869325838, + "t": 20.788390869325838, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 21.855924730021965, + "t": 20.855924730021965, "z": 5, "pan": -0.3125, "vis": true }, { - "t": 22.01709726131601, + "t": 21.01709726131601, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 21.90821088835348, + "t": 20.90821088835348, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.089026002888048, + "t": 21.089026002888048, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.116872226804542, + "t": 21.116872226804542, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.21636941208023, + "t": 21.21636941208023, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.020395299977714, + "t": 21.020395299977714, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.174229367475796, + "t": 21.174229367475796, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.178543193844803, + "t": 21.178543193844803, "z": 5, "pan": -0.3125, "vis": false }, { - "t": 22.22480149296444, + "t": 21.22480149296444, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 22.119814789120326, + "t": 21.119814789120326, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 22.18622182687066, + "t": 21.18622182687066, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 22.073647236860214, + "t": 21.073647236860214, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 22.154465110420613, + "t": 21.154465110420613, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.904030874930257, + "t": 20.904030874930257, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 22.024303352288268, + "t": 21.024303352288268, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 22.031609997667662, + "t": 21.031609997667662, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.95368797451686, + "t": 20.95368797451686, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.78911527741237, + "t": 20.78911527741237, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.716642119341074, + "t": 20.716642119341074, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.549936005130984, + "t": 20.549936005130984, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.54596911914296, + "t": 20.54596911914296, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.441603852800267, + "t": 20.441603852800267, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.206704408416687, + "t": 20.206704408416687, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.179324635353154, + "t": 20.179324635353154, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.195635746113695, + "t": 20.195635746113695, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.358614853728, + "t": 20.358614853728, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.479113632662354, + "t": 20.479113632662354, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.616101120526164, + "t": 20.616101120526164, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.505667341064925, + "t": 20.505667341064925, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.807972789825783, + "t": 20.807972789825783, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.913068169418175, + "t": 20.913068169418175, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.963240201073432, + "t": 20.963240201073432, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.926761558774952, + "t": 20.926761558774952, "z": 5, "pan": -0.1875, "vis": true }, { - "t": 21.998924910363304, + "t": 20.998924910363304, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 21.967403992109375, + "t": 20.967403992109375, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 22.103437562150525, + "t": 21.103437562150525, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 22.154880416239514, + "t": 21.154880416239514, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 22.053415834672435, + "t": 21.053415834672435, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 22.12260813147281, + "t": 21.12260813147281, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 22.113194169867718, + "t": 21.113194169867718, "z": 5, "pan": -0.1875, "vis": false }, { - "t": 22.10852292219315, + "t": 21.10852292219315, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.165738861374393, + "t": 21.165738861374393, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.10269505189025, + "t": 21.10269505189025, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.042594511616308, + "t": 21.042594511616308, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.023249011426582, + "t": 21.023249011426582, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.13086396921896, + "t": 21.13086396921896, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.86124213254495, + "t": 20.86124213254495, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.005474191899992, + "t": 21.005474191899992, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.77235965585769, + "t": 20.77235965585769, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.660245418408138, + "t": 20.660245418408138, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.574435000525632, + "t": 20.574435000525632, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.486764204702045, + "t": 20.486764204702045, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.517111663516456, + "t": 20.517111663516456, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.44667881139106, + "t": 20.44667881139106, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.256483360793997, + "t": 20.256483360793997, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 20.95639747655562, + "t": 19.95639747655562, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.081329510370033, + "t": 20.081329510370033, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.11187109378825, + "t": 20.11187109378825, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.282522243565175, + "t": 20.282522243565175, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.352819163319523, + "t": 20.352819163319523, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.552927403684837, + "t": 20.552927403684837, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.62105389868829, + "t": 20.62105389868829, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.706728384199646, + "t": 20.706728384199646, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.799298320829045, + "t": 20.799298320829045, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 22.01286855605102, + "t": 21.01286855605102, "z": 5, "pan": -0.0625, "vis": true }, { - "t": 21.871429060767806, + "t": 20.871429060767806, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 22.11857810017855, + "t": 21.11857810017855, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 21.991418841566126, + "t": 20.991418841566126, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 22.136654451669468, + "t": 21.136654451669468, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 22.05118464883849, + "t": 21.05118464883849, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 22.0946841575023, + "t": 21.0946841575023, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 22.267923917500998, + "t": 21.267923917500998, "z": 5, "pan": -0.0625, "vis": false }, { - "t": 22.039282530804503, + "t": 21.039282530804503, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 22.217853399132437, + "t": 21.217853399132437, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.98465709785735, + "t": 20.98465709785735, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 22.04921514120829, + "t": 21.04921514120829, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 22.139408339862324, + "t": 21.139408339862324, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 22.065054575560776, + "t": 21.065054575560776, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.978581907368884, + "t": 20.978581907368884, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.97053643526471, + "t": 20.97053643526471, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.79363694682555, + "t": 20.79363694682555, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.69623033604141, + "t": 20.69623033604141, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.673258283070677, + "t": 20.673258283070677, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.629986304547263, + "t": 20.629986304547263, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.413225159724018, + "t": 20.413225159724018, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.21412125736775, + "t": 20.21412125736775, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.056508393767874, + "t": 20.056508393767874, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 20.99749767558907, + "t": 19.99749767558907, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.052249244029678, + "t": 20.052249244029678, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.103557184142108, + "t": 20.103557184142108, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.25346726967563, + "t": 20.25346726967563, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.56207430891343, + "t": 20.56207430891343, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.52113267567002, + "t": 20.52113267567002, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.806701876127125, + "t": 20.806701876127125, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.68917706597914, + "t": 20.68917706597914, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.778880898696862, + "t": 20.778880898696862, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.948077609069333, + "t": 20.948077609069333, "z": 5, "pan": 0.0625, "vis": true }, { - "t": 21.9119294029841, + "t": 20.9119294029841, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 21.89439610818007, + "t": 20.89439610818007, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 21.961047094415417, + "t": 20.961047094415417, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 22.020064578573894, + "t": 21.020064578573894, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 22.15809621122537, + "t": 21.15809621122537, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 22.133589734433713, + "t": 21.133589734433713, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 22.197316088039617, + "t": 21.197316088039617, "z": 5, "pan": 0.0625, "vis": false }, { - "t": 22.26354391118189, + "t": 21.26354391118189, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 22.24623445862139, + "t": 21.24623445862139, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 22.138502517352382, + "t": 21.138502517352382, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 22.076758683186558, + "t": 21.076758683186558, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 22.030111932281883, + "t": 21.030111932281883, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 22.140396842023492, + "t": 21.140396842023492, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.95145004785755, + "t": 20.95145004785755, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.866150193023184, + "t": 20.866150193023184, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.933805439355673, + "t": 20.933805439355673, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.902993781431512, + "t": 20.902993781431512, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.719741909324895, + "t": 20.719741909324895, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.733471188849403, + "t": 20.733471188849403, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.398123466293033, + "t": 20.398123466293033, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.32292945854643, + "t": 20.32292945854643, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.30332353214876, + "t": 20.30332353214876, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.22942104676818, + "t": 20.22942104676818, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.164853388756246, + "t": 20.164853388756246, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.327984138607775, + "t": 20.327984138607775, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.43681832947639, + "t": 20.43681832947639, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.38287702119915, + "t": 20.38287702119915, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.55745300822645, + "t": 20.55745300822645, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.63295199317289, + "t": 20.63295199317289, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.687068549255642, + "t": 20.687068549255642, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.80710847165075, + "t": 20.80710847165075, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.82868148978918, + "t": 20.82868148978918, "z": 5, "pan": 0.1875, "vis": true }, { - "t": 21.9898159904993, + "t": 20.9898159904993, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.06302108723655, + "t": 21.06302108723655, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.0419644419659, + "t": 21.0419644419659, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.072328417808084, + "t": 21.072328417808084, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.11044797548349, + "t": 21.11044797548349, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.0574081812234, + "t": 21.0574081812234, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.16394589825487, + "t": 21.16394589825487, "z": 5, "pan": 0.1875, "vis": false }, { - "t": 22.19202345001897, + "t": 21.19202345001897, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 22.077051843638422, + "t": 21.077051843638422, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 22.080434956459875, + "t": 21.080434956459875, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 22.132567947256554, + "t": 21.132567947256554, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.97750957234532, + "t": 20.97750957234532, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.938413007554672, + "t": 20.938413007554672, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.923478644741323, + "t": 20.923478644741323, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.97002379425214, + "t": 20.97002379425214, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.8791334008889, + "t": 20.8791334008889, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.89975663845571, + "t": 20.89975663845571, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.775719484769034, + "t": 20.775719484769034, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.62555069704809, + "t": 20.62555069704809, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.58912326877122, + "t": 20.58912326877122, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.39525148820385, + "t": 20.39525148820385, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.468363177725177, + "t": 20.468363177725177, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.445182952285304, + "t": 20.445182952285304, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.29772680218445, + "t": 20.29772680218445, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.48135919611845, + "t": 20.48135919611845, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.56869967509127, + "t": 20.56869967509127, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.467659155457035, + "t": 20.467659155457035, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.664538752227934, + "t": 20.664538752227934, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.725159708443016, + "t": 20.725159708443016, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.804284561928007, + "t": 20.804284561928007, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.944113492855326, + "t": 20.944113492855326, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.945456054712537, + "t": 20.945456054712537, "z": 5, "pan": 0.3125, "vis": true }, { - "t": 21.944742472735822, + "t": 20.944742472735822, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 22.02938513630739, + "t": 21.02938513630739, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 21.978933869592264, + "t": 20.978933869592264, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 22.158013206259504, + "t": 21.158013206259504, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 22.197399121999283, + "t": 21.197399121999283, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 22.10446817767177, + "t": 21.10446817767177, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 22.12989195254653, + "t": 21.12989195254653, "z": 5, "pan": 0.3125, "vis": false }, { - "t": 22.167189421862307, + "t": 21.167189421862307, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.163626754878354, + "t": 21.163626754878354, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.237320514793524, + "t": 21.237320514793524, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.225382615065733, + "t": 21.225382615065733, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.171440609842794, + "t": 21.171440609842794, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.942708660378088, + "t": 20.942708660378088, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.92449822016917, + "t": 20.92449822016917, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.063197213384832, + "t": 21.063197213384832, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.024413379909458, + "t": 21.024413379909458, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.807484647279363, + "t": 20.807484647279363, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.81668929037077, + "t": 20.81668929037077, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.714002255546063, + "t": 20.714002255546063, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.6871703101186, + "t": 20.6871703101186, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.695325932328153, + "t": 20.695325932328153, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.562782947875803, + "t": 20.562782947875803, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.53602504192772, + "t": 20.53602504192772, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.376879011310326, + "t": 20.376879011310326, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.5522981479004, + "t": 20.5522981479004, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.583502362994707, + "t": 20.583502362994707, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.75705467955059, + "t": 20.75705467955059, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.68254785561983, + "t": 20.68254785561983, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.933896121086466, + "t": 20.933896121086466, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.856399416760425, + "t": 20.856399416760425, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.971989380032436, + "t": 20.971989380032436, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 21.909434444149454, + "t": 20.909434444149454, "z": 5, "pan": 0.4375, "vis": true }, { - "t": 22.06625513076096, + "t": 21.06625513076096, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 22.119315060549738, + "t": 21.119315060549738, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 21.996708240656535, + "t": 20.996708240656535, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 22.144666135088688, + "t": 21.144666135088688, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 22.142957315014563, + "t": 21.142957315014563, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 22.21792478574098, + "t": 21.21792478574098, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 22.12014868336707, + "t": 21.12014868336707, "z": 5, "pan": 0.4375, "vis": false }, { - "t": 22.178878439486056, + "t": 21.178878439486056, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.039639141236457, + "t": 21.039639141236457, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.049369527621245, + "t": 21.049369527621245, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.186626218903353, + "t": 21.186626218903353, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.057400512309783, + "t": 21.057400512309783, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.075509289044163, + "t": 21.075509289044163, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.98227386438006, + "t": 20.98227386438006, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.10759174435882, + "t": 21.10759174435882, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.8782669079253, + "t": 20.8782669079253, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.04395214025954, + "t": 21.04395214025954, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.924427638483653, + "t": 20.924427638483653, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.946413436422823, + "t": 20.946413436422823, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.867804388420655, + "t": 20.867804388420655, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.776319351030153, + "t": 20.776319351030153, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.578935428904565, + "t": 20.578935428904565, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.551865233373668, + "t": 20.551865233373668, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.54636877922067, + "t": 20.54636877922067, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.686012417645326, + "t": 20.686012417645326, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.745969782664673, + "t": 20.745969782664673, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.8264619117492, + "t": 20.8264619117492, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.76764440264511, + "t": 20.76764440264511, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.858232047599696, + "t": 20.858232047599696, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.9667636410696, + "t": 20.9667636410696, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 21.913651851629115, + "t": 20.913651851629115, "z": 5, "pan": 0.5625, "vis": true }, { - "t": 22.005550130956365, + "t": 21.005550130956365, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.12723964264411, + "t": 21.12723964264411, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.075048210229475, + "t": 21.075048210229475, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.169512876388858, + "t": 21.169512876388858, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.058720402090785, + "t": 21.058720402090785, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.263062426288094, + "t": 21.263062426288094, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.115905482797032, + "t": 21.115905482797032, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.117718223940397, + "t": 21.117718223940397, "z": 5, "pan": 0.5625, "vis": false }, { - "t": 22.150084762403473, + "t": 21.150084762403473, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.152856650756156, + "t": 21.152856650756156, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.263688896445036, + "t": 21.263688896445036, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.075489672068937, + "t": 21.075489672068937, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.078653288215563, + "t": 21.078653288215563, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.188691805336365, + "t": 21.188691805336365, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.081660761533175, + "t": 21.081660761533175, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.08132102075005, + "t": 21.08132102075005, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.936534239783526, + "t": 20.936534239783526, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.897157610950778, + "t": 20.897157610950778, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.900622526737305, + "t": 20.900622526737305, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.001128045499968, + "t": 21.001128045499968, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.96078406888921, + "t": 20.96078406888921, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.803699749188205, + "t": 20.803699749188205, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.921299989286037, + "t": 20.921299989286037, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.91168369158254, + "t": 20.91168369158254, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.881789330197833, + "t": 20.881789330197833, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.801570514212383, + "t": 20.801570514212383, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.844135160425573, + "t": 20.844135160425573, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.94143075735729, + "t": 20.94143075735729, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.89193962027899, + "t": 20.89193962027899, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 21.95159898782739, + "t": 20.95159898782739, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.13834534927146, + "t": 21.13834534927146, "z": 5, "pan": 0.6875, "vis": true }, { - "t": 22.087886864415253, + "t": 21.087886864415253, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.142838531692668, + "t": 21.142838531692668, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.156856940354906, + "t": 21.156856940354906, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.100585479820555, + "t": 21.100585479820555, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.150711849010847, + "t": 21.150711849010847, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.244508143345353, + "t": 21.244508143345353, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.186123620781995, + "t": 21.186123620781995, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.235456261403993, + "t": 21.235456261403993, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.142849259362414, + "t": 21.142849259362414, "z": 5, "pan": 0.6875, "vis": false }, { - "t": 22.16672312554686, + "t": 21.16672312554686, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.270299612874044, + "t": 21.270299612874044, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.191087013602417, + "t": 21.191087013602417, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.249059833658727, + "t": 21.249059833658727, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.093610699087083, + "t": 21.093610699087083, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.25465614326591, + "t": 21.25465614326591, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.20568619004401, + "t": 21.20568619004401, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.038889840216957, + "t": 21.038889840216957, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.187838853046173, + "t": 21.187838853046173, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.152492968578162, + "t": 21.152492968578162, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.100473188684255, + "t": 21.100473188684255, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 21.987813384178924, + "t": 20.987813384178924, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 21.943310947621733, + "t": 20.943310947621733, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.040120256540536, + "t": 21.040120256540536, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 21.92911578908677, + "t": 20.92911578908677, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 21.893945071918242, + "t": 20.893945071918242, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 21.91085141487421, + "t": 20.91085141487421, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.070763619895747, + "t": 21.070763619895747, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.056509575202465, + "t": 21.056509575202465, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 21.993512952195868, + "t": 20.993512952195868, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.162756876605815, + "t": 21.162756876605815, "z": 5, "pan": 0.8125, "vis": true }, { - "t": 22.15015816896411, + "t": 21.15015816896411, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 21.985990634770083, + "t": 20.985990634770083, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.146078007091035, + "t": 21.146078007091035, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.12187048211447, + "t": 21.12187048211447, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.09870796666796, + "t": 21.09870796666796, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.2461909589282, + "t": 21.2461909589282, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.209887002602475, + "t": 21.209887002602475, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.078965329662715, + "t": 21.078965329662715, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.215917490882862, + "t": 21.215917490882862, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.16987157800708, + "t": 21.16987157800708, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.191036578533055, + "t": 21.191036578533055, "z": 5, "pan": 0.8125, "vis": false }, { - "t": 22.212128058226664, + "t": 21.212128058226664, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.239411807138044, + "t": 21.239411807138044, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.155798947947943, + "t": 21.155798947947943, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.137214274888464, + "t": 21.137214274888464, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.216482080741443, + "t": 21.216482080741443, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.18461386468087, + "t": 21.18461386468087, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.08557795928823, + "t": 21.08557795928823, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.216059645322918, + "t": 21.216059645322918, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.235163657346085, + "t": 21.235163657346085, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.1428843987576, + "t": 21.1428843987576, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.236342152164564, + "t": 21.236342152164564, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.05665075221971, + "t": 21.05665075221971, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.266424667415073, + "t": 21.266424667415073, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.031481048718575, + "t": 21.031481048718575, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.157431949946496, + "t": 21.157431949946496, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.1753258227538, + "t": 21.1753258227538, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.11885054509132, + "t": 21.11885054509132, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.056591542103064, + "t": 21.056591542103064, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.136275510694187, + "t": 21.136275510694187, "z": 5, "pan": 0.9375, "vis": true }, { - "t": 22.258268574065735, + "t": 21.258268574065735, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.25412952868941, + "t": 21.25412952868941, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.139455798453305, + "t": 21.139455798453305, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.183047489721393, + "t": 21.183047489721393, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.23096161812892, + "t": 21.23096161812892, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.16749247592479, + "t": 21.16749247592479, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.12999902423017, + "t": 21.12999902423017, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.228731009776826, + "t": 21.228731009776826, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.216234095656446, + "t": 21.216234095656446, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.098020799965482, + "t": 21.098020799965482, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.24823527735704, + "t": 21.24823527735704, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.287483006366173, + "t": 21.287483006366173, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.21583412727384, + "t": 21.21583412727384, "z": 5, "pan": 0.9375, "vis": false }, { - "t": 22.221723006241128, + "t": 21.221723006241128, "z": 5, "pan": 1, "vis": false }, { - "t": 22.12974843008111, + "t": 21.12974843008111, "z": 5, "pan": 1, "vis": false }, { - "t": 22.136075631948454, + "t": 21.136075631948454, "z": 5, "pan": 1, "vis": false }, { - "t": 22.35130259596161, + "t": 21.35130259596161, "z": 5, "pan": 1, "vis": false }, { - "t": 22.302054503656535, + "t": 21.302054503656535, "z": 5, "pan": 1, "vis": false }, { - "t": 22.35134760319089, + "t": 21.35134760319089, "z": 5, "pan": 1, "vis": false }, { - "t": 22.298175707137812, + "t": 21.298175707137812, "z": 5, "pan": 1, "vis": false }, { - "t": 22.29242427544756, + "t": 21.29242427544756, "z": 5, "pan": 1, "vis": false }, { - "t": 22.13545568769469, + "t": 21.13545568769469, "z": 5, "pan": 1, "vis": false }, { - "t": 22.327275540479327, + "t": 21.327275540479327, "z": 5, "pan": 1, "vis": false }, { - "t": 22.352660094172087, + "t": 21.352660094172087, "z": 5, "pan": 1, "vis": false }, { - "t": 22.239197459183774, + "t": 21.239197459183774, "z": 5, "pan": 1, "vis": false }, { - "t": 22.223674678981038, + "t": 21.223674678981038, "z": 5, "pan": 1, "vis": false }, { - "t": 22.228610512377916, + "t": 21.228610512377916, "z": 5, "pan": 1, "vis": false }, { - "t": 22.404216117013373, + "t": 21.404216117013373, "z": 5, "pan": 1, "vis": false }, { - "t": 22.425283919995323, + "t": 21.425283919995323, "z": 5, "pan": 1, "vis": false }, { - "t": 22.402901319958687, + "t": 21.402901319958687, "z": 5, "pan": 1, "vis": false }, { - "t": 22.361427369606847, + "t": 21.361427369606847, "z": 5, "pan": 1, "vis": false }, { - "t": 22.41541561760151, + "t": 21.41541561760151, "z": 5, "pan": 1, "vis": false }, { - "t": 22.36571458413145, + "t": 21.36571458413145, "z": 5, "pan": 1, "vis": false }, { - "t": 22.360831216964222, + "t": 21.360831216964222, "z": 5, "pan": 1, "vis": false }, { - "t": 22.20388770458263, + "t": 21.20388770458263, "z": 5, "pan": 1, "vis": false }, { - "t": 22.383737950816638, + "t": 21.383737950816638, "z": 5, "pan": 1, "vis": false }, { - "t": 22.171511950662104, + "t": 21.171511950662104, "z": 5, "pan": 1, "vis": false }, { - "t": 22.308074391044954, + "t": 21.308074391044954, "z": 5, "pan": 1, "vis": false }, { - "t": 22.220159687842344, + "t": 21.220159687842344, "z": 5, "pan": 1, "vis": false }, { - "t": 22.301826371345086, + "t": 21.301826371345086, "z": 5, "pan": 1, "vis": false }, { - "t": 22.23212712444089, + "t": 21.23212712444089, "z": 5, "pan": 1, "vis": false }, { - "t": 22.126082172835186, + "t": 21.126082172835186, "z": 5, "pan": 1, "vis": false }, { - "t": 22.250976905289686, + "t": 21.250976905289686, "z": 5, "pan": 1, "vis": false }, { - "t": 22.224243556052308, + "t": 21.224243556052308, "z": 5, "pan": 1, "vis": false }, { - "t": 22.295811984842498, + "t": 21.295811984842498, "z": 5, "pan": 1, "vis": false }, { - "t": 22.275038195153375, + "t": 21.275038195153375, "z": 5, "pan": 1, "vis": false }, { - "t": 22.272235977048506, + "t": 21.272235977048506, "z": 5, "pan": 1, "vis": false }, { - "t": 22.326921982958265, + "t": 21.326921982958265, "z": 5, "pan": 1, "vis": false }, { - "t": 22.347071986050874, + "t": 21.347071986050874, "z": 5, "pan": 1, "vis": false }, { - "t": 22.377699115481153, + "t": 21.377699115481153, "z": 5, "pan": 1, "vis": false }, { - "t": 22.21653080357688, + "t": 21.21653080357688, "z": 5, "pan": 1, "vis": false }, { - "t": 22.280295269488537, + "t": 21.280295269488537, "z": 5, "pan": 1, "vis": false }, { - "t": 22.34667333981054, + "t": 21.34667333981054, "z": 5, "pan": 1, "vis": false }, { - "t": 22.222005184892126, + "t": 21.222005184892126, "z": 5, "pan": 1, "vis": false }, { - "t": 22.40633106468678, + "t": 21.40633106468678, "z": 5, "pan": 1, "vis": false }, { - "t": 22.434822497616157, + "t": 21.434822497616157, "z": 5, "pan": 1, "vis": false }, { - "t": 22.34616688566866, + "t": 21.34616688566866, "z": 5, "pan": 1, "vis": false }, { - "t": 22.314053043188572, + "t": 21.314053043188572, "z": 5, "pan": 1, "vis": false }, { - "t": 22.38038707684635, + "t": 21.38038707684635, "z": 5, "pan": 1, "vis": false }, { - "t": 22.5644798534836, + "t": 21.5644798534836, "z": 5, "pan": 1, "vis": false }, { - "t": 22.54743801725159, + "t": 21.54743801725159, "z": 5, "pan": 1, "vis": false }, { - "t": 22.380346706412716, + "t": 21.380346706412716, "z": 5, "pan": 1, "vis": false }, { - "t": 22.567926536211, + "t": 21.567926536211, "z": 5, "pan": 1, "vis": false }, { - "t": 22.554371753140007, + "t": 21.554371753140007, "z": 5, "pan": 1, "vis": false }, { - "t": 22.40385509780465, + "t": 21.40385509780465, "z": 5, "pan": 1, "vis": false }, { - "t": 22.356506933850916, + "t": 21.356506933850916, "z": 5, "pan": 1, "vis": false }, { - "t": 22.36570053936462, + "t": 21.36570053936462, "z": 5, "pan": 1, "vis": false }, { - "t": 22.2530264847576, + "t": 21.2530264847576, "z": 5, "pan": 1, "vis": false }, { - "t": 22.239238598529212, + "t": 21.239238598529212, "z": 5, "pan": 1, "vis": false }, { - "t": 22.284444747013584, + "t": 21.284444747013584, "z": 5, "pan": 1, "vis": false }, { - "t": 22.217437363200226, + "t": 21.217437363200226, "z": 5, "pan": 1, "vis": false }, { - "t": 22.240657582668298, + "t": 21.240657582668298, "z": 5, "pan": 1, "vis": false }, { - "t": 22.322363888138977, + "t": 21.322363888138977, "z": 5, "pan": 1, "vis": false }, { - "t": 22.29978735135958, + "t": 21.29978735135958, "z": 5, "pan": 1, "vis": false }, { - "t": 22.357942127504696, + "t": 21.357942127504696, "z": 5, "pan": 1, "vis": false }, { - "t": 22.223794115160853, + "t": 21.223794115160853, "z": 5, "pan": 1, "vis": false }, { - "t": 22.14713432683215, + "t": 21.14713432683215, "z": 5, "pan": 1, "vis": false }, { - "t": 22.371496106510055, + "t": 21.371496106510055, "z": 5, "pan": 1, "vis": false }, { - "t": 22.166174836139167, + "t": 21.166174836139167, "z": 5, "pan": 1, "vis": false }, { - "t": 22.227492345847956, + "t": 21.227492345847956, "z": 5, "pan": 1, "vis": false }, { - "t": 22.373503589802297, + "t": 21.373503589802297, "z": 5, "pan": 1, "vis": false }, { - "t": 22.19219431727379, + "t": 21.19219431727379, "z": 5, "pan": 1, "vis": false }, { - "t": 22.2787092811871, + "t": 21.2787092811871, "z": 5, "pan": 1, "vis": false }, { - "t": 22.280224034109334, + "t": 21.280224034109334, "z": 5, "pan": 1, "vis": false }, { - "t": 22.376821844146892, + "t": 21.376821844146892, "z": 5, "pan": 1, "vis": false }, { - "t": 22.492565831783743, + "t": 21.492565831783743, "z": 5, "pan": 1, "vis": false }, { - "t": 22.37759880470268, + "t": 21.37759880470268, "z": 5, "pan": 1, "vis": false }, { - "t": 22.48029473838087, + "t": 21.48029473838087, "z": 5, "pan": 1, "vis": false }, { - "t": 22.625150151924927, + "t": 21.625150151924927, "z": 5, "pan": 1, "vis": false }, { - "t": 22.53555419825784, + "t": 21.53555419825784, "z": 5, "pan": 1, "vis": false }, { - "t": 22.485471056964787, + "t": 21.485471056964787, "z": 5, "pan": 1, "vis": false }, { - "t": 22.63763547481, + "t": 21.63763547481, "z": 5, "pan": 1, "vis": false }, { - "t": 22.541715567872416, + "t": 21.541715567872416, "z": 5, "pan": 1, "vis": false }, { - "t": 22.55111415780354, + "t": 21.55111415780354, "z": 5, "pan": 1, "vis": false }, { - "t": 22.675501357368322, + "t": 21.675501357368322, "z": 5, "pan": 1, "vis": false }, { - "t": 22.55180423215032, + "t": 21.55180423215032, "z": 5, "pan": 1, "vis": false }, { - "t": 22.620684553306035, + "t": 21.620684553306035, "z": 5, "pan": 1, "vis": false }, { - "t": 22.488747799600034, + "t": 21.488747799600034, "z": 5, "pan": 1, "vis": false }, { - "t": 22.37235967868323, + "t": 21.37235967868323, "z": 5, "pan": 1, "vis": false }, { - "t": 22.538460924867366, + "t": 21.538460924867366, "z": 5, "pan": 1, "vis": false }, { - "t": 22.431895244575607, + "t": 21.431895244575607, "z": 5, "pan": 1, "vis": false }, { - "t": 22.344618549565542, + "t": 21.344618549565542, "z": 5, "pan": 1, "vis": false }, { - "t": 22.370119438072727, + "t": 21.370119438072727, "z": 5, "pan": 1, "vis": false }, { - "t": 22.29377045909484, + "t": 21.29377045909484, "z": 5, "pan": 1, "vis": false }, { - "t": 22.235722787809067, + "t": 21.235722787809067, "z": 5, "pan": 1, "vis": false }, { - "t": 22.3681174801847, + "t": 21.3681174801847, "z": 5, "pan": 1, "vis": false }, { - "t": 22.318285288872822, + "t": 21.318285288872822, "z": 5, "pan": 1, "vis": false }, { - "t": 22.285435071790662, + "t": 21.285435071790662, "z": 5, "pan": 1, "vis": false }, { - "t": 22.269223634789135, + "t": 21.269223634789135, "z": 5, "pan": 1, "vis": false }, { - "t": 22.391902576001705, + "t": 21.391902576001705, "z": 5, "pan": 1, "vis": false }, { - "t": 22.23949294095116, + "t": 21.23949294095116, "z": 5, "pan": 1, "vis": false }, { - "t": 22.175518791192157, + "t": 21.175518791192157, "z": 5, "pan": 1, "vis": false }, { - "t": 22.433344948352048, + "t": 21.433344948352048, "z": 5, "pan": 1, "vis": false }, { - "t": 22.36563226244199, + "t": 21.36563226244199, "z": 5, "pan": 1, "vis": false }, { - "t": 22.387990263129925, + "t": 21.387990263129925, "z": 5, "pan": 1, "vis": false }, { - "t": 22.27745844383665, + "t": 21.27745844383665, "z": 5, "pan": 1, "vis": false }, { - "t": 22.299912712150785, + "t": 21.299912712150785, "z": 5, "pan": 1, "vis": false }, { - "t": 22.414414866803135, + "t": 21.414414866803135, "z": 5, "pan": 1, "vis": false }, { - "t": 22.62131212261366, + "t": 21.62131212261366, "z": 5, "pan": 1, "vis": false }, { - "t": 22.558119488977464, + "t": 21.558119488977464, "z": 5, "pan": 1, "vis": false }, { - "t": 22.51602155005366, + "t": 21.51602155005366, "z": 5, "pan": 1, "vis": false }, { - "t": 22.561923626997036, + "t": 21.561923626997036, "z": 5, "pan": 1, "vis": false }, { - "t": 22.621841436576148, + "t": 21.621841436576148, "z": 5, "pan": 1, "vis": false }, { - "t": 22.64382375851363, + "t": 21.64382375851363, "z": 5, "pan": 1, "vis": false }, { - "t": 22.732897523782363, + "t": 21.732897523782363, "z": 5, "pan": 1, "vis": false }, { - "t": 22.85797665772449, + "t": 21.85797665772449, "z": 5, "pan": 1, "vis": false }, { - "t": 22.887641959908244, + "t": 21.887641959908244, "z": 5, "pan": 1, "vis": false }, { - "t": 22.734398705423274, + "t": 21.734398705423274, "z": 5, "pan": 1, "vis": false }, { - "t": 22.674639163728482, + "t": 21.674639163728482, "z": 5, "pan": 1, "vis": false }, { - "t": 22.497476154237514, + "t": 21.497476154237514, "z": 5, "pan": 1, "vis": false }, { - "t": 22.658313160613805, + "t": 21.658313160613805, "z": 5, "pan": 1, "vis": false }, { - "t": 22.471664062134277, + "t": 21.471664062134277, "z": 5, "pan": 1, "vis": false }, { - "t": 22.383505873776233, + "t": 21.383505873776233, "z": 5, "pan": 1, "vis": false }, { - "t": 22.38774278657627, + "t": 21.38774278657627, "z": 5, "pan": 1, "vis": false }, { - "t": 22.3390856774796, + "t": 21.3390856774796, "z": 5, "pan": 1, "vis": false }, { - "t": 22.344717672892106, + "t": 21.344717672892106, "z": 5, "pan": 1, "vis": false }, { - "t": 22.44109873965669, + "t": 21.44109873965669, "z": 5, "pan": 1, "vis": false }, { - "t": 22.276073524146444, + "t": 21.276073524146444, "z": 5, "pan": 1, "vis": false }, { - "t": 22.369882603743633, + "t": 21.369882603743633, "z": 5, "pan": 1, "vis": false }, { - "t": 22.302595820955002, + "t": 21.302595820955002, "z": 5, "pan": 1, "vis": false }, { - "t": 22.323744523458096, + "t": 21.323744523458096, "z": 5, "pan": 1, "vis": false }, { - "t": 22.226821141103986, + "t": 21.226821141103986, "z": 5, "pan": 1, "vis": false }, { - "t": 22.40087432581277, + "t": 21.40087432581277, "z": 5, "pan": 1, "vis": false }, { - "t": 22.37237022103821, + "t": 21.37237022103821, "z": 5, "pan": 1, "vis": false }, { - "t": 22.24233088917804, + "t": 21.24233088917804, "z": 5, "pan": 1, "vis": false }, { - "t": 22.241167137762158, + "t": 21.241167137762158, "z": 5, "pan": 1, "vis": false }, { - "t": 22.28949246800387, + "t": 21.28949246800387, "z": 5, "pan": 1, "vis": false }, { - "t": 22.352397366165647, + "t": 21.352397366165647, "z": 5, "pan": 1, "vis": false }, { - "t": 22.43299634194746, + "t": 21.43299634194746, "z": 5, "pan": 1, "vis": false }, { - "t": 22.56577728183615, + "t": 21.56577728183615, "z": 5, "pan": 1, "vis": false }, { - "t": 22.501402258295318, + "t": 21.501402258295318, "z": 5, "pan": 1, "vis": false }, { - "t": 22.74856921350256, + "t": 21.74856921350256, "z": 5, "pan": 1, "vis": false }, { - "t": 22.719657080376948, + "t": 21.719657080376948, "z": 5, "pan": 1, "vis": false }, { - "t": 22.73958899526953, + "t": 21.73958899526953, "z": 5, "pan": 1, "vis": false }, { - "t": 22.92447666089974, + "t": 21.92447666089974, "z": 5, "pan": 1, "vis": false }, { - "t": 22.949824203038087, + "t": 21.949824203038087, "z": 5, "pan": 1, "vis": false }, { - "t": 22.99408580408805, + "t": 21.99408580408805, "z": 5, "pan": 1, "vis": false }, { - "t": 23.032946812085715, + "t": 22.032946812085715, "z": 5, "pan": 1, "vis": false }, { - "t": 23.024735958817967, + "t": 22.024735958817967, "z": 5, "pan": 1, "vis": false }, { - "t": 22.785439164461874, + "t": 21.785439164461874, "z": 5, "pan": 1, "vis": false }, { - "t": 22.67827351482716, + "t": 21.67827351482716, "z": 5, "pan": 1, "vis": false }, { - "t": 22.69439234771663, + "t": 21.69439234771663, "z": 5, "pan": 1, "vis": false }, { - "t": 22.50935522862444, + "t": 21.50935522862444, "z": 5, "pan": 1, "vis": false }, { - "t": 22.589910289412597, + "t": 21.589910289412597, "z": 5, "pan": 1, "vis": false }, { - "t": 22.440336060735643, + "t": 21.440336060735643, "z": 5, "pan": 1, "vis": false }, { - "t": 22.343605868628938, + "t": 21.343605868628938, "z": 5, "pan": 1, "vis": false }, { - "t": 22.376515228020658, + "t": 21.376515228020658, "z": 5, "pan": 1, "vis": false }, { - "t": 22.313874758462717, + "t": 21.313874758462717, "z": 5, "pan": 1, "vis": false }, { - "t": 22.301600176003372, + "t": 21.301600176003372, "z": 5, "pan": 1, "vis": false }, { - "t": 22.280044017115145, + "t": 21.280044017115145, "z": 5, "pan": 1, "vis": false }, { - "t": 22.296576023057057, + "t": 21.296576023057057, "z": 5, "pan": 1, "vis": false }, { - "t": 22.361130875613522, + "t": 21.361130875613522, "z": 5, "pan": 1, "vis": false }, { - "t": 22.2231284386871, + "t": 21.2231284386871, "z": 5, "pan": 1, "vis": false }, { - "t": 22.353417320934497, + "t": 21.353417320934497, "z": 5, "pan": 1, "vis": false }, { - "t": 22.311923333193697, + "t": 21.311923333193697, "z": 5, "pan": 1, "vis": false }, { - "t": 22.276773763666846, + "t": 21.276773763666846, "z": 5, "pan": 1, "vis": false }, { - "t": 22.329333907425315, + "t": 21.329333907425315, "z": 5, "pan": 1, "vis": false }, { - "t": 22.35233041385155, + "t": 21.35233041385155, "z": 5, "pan": 1, "vis": false }, { - "t": 22.38406424757318, + "t": 21.38406424757318, "z": 5, "pan": 1, "vis": false }, { - "t": 22.44036343448253, + "t": 21.44036343448253, "z": 5, "pan": 1, "vis": false }, { - "t": 22.53616014212301, + "t": 21.53616014212301, "z": 5, "pan": 1, "vis": false }, { - "t": 22.64409153005346, + "t": 21.64409153005346, "z": 5, "pan": 1, "vis": false }, { - "t": 22.51520842446965, + "t": 21.51520842446965, "z": 5, "pan": 1, "vis": false }, { - "t": 22.642287542598055, + "t": 21.642287542598055, "z": 5, "pan": 1, "vis": false }, { - "t": 22.833901050522606, + "t": 21.833901050522606, "z": 5, "pan": 1, "vis": false }, { - "t": 22.786472336418566, + "t": 21.786472336418566, "z": 5, "pan": 1, "vis": false }, { - "t": 22.92005777096873, + "t": 21.92005777096873, "z": 5, "pan": 1, "vis": false }, { - "t": 23.162794527287314, + "t": 22.162794527287314, "z": 5, "pan": 1, "vis": false }, { - "t": 23.126522335982997, + "t": 22.126522335982997, "z": 5, "pan": 1, "vis": false }, { - "t": 23.150363829607215, + "t": 22.150363829607215, "z": 5, "pan": 1, "vis": false }, { - "t": 23.0569836071494, + "t": 22.0569836071494, "z": 5, "pan": 1, "vis": false }, { - "t": 22.934594437068792, + "t": 21.934594437068792, "z": 5, "pan": 1, "vis": false }, { - "t": 22.84869198976728, + "t": 21.84869198976728, "z": 5, "pan": 1, "vis": false }, { - "t": 22.766468290108953, + "t": 21.766468290108953, "z": 5, "pan": 1, "vis": false }, { - "t": 22.695202368422315, + "t": 21.695202368422315, "z": 5, "pan": 1, "vis": false }, { - "t": 22.615806237542508, + "t": 21.615806237542508, "z": 5, "pan": 1, "vis": false }, { - "t": 22.61503692936408, + "t": 21.61503692936408, "z": 5, "pan": 1, "vis": false }, { - "t": 22.37745312767109, + "t": 21.37745312767109, "z": 5, "pan": 1, "vis": false }, { - "t": 22.564567121794205, + "t": 21.564567121794205, "z": 5, "pan": 1, "vis": false }, { - "t": 22.39338780660728, + "t": 21.39338780660728, "z": 5, "pan": 1, "vis": false }, { - "t": 22.482001559123805, + "t": 21.482001559123805, "z": 5, "pan": 1, "vis": false }, { - "t": 22.425994324143655, + "t": 21.425994324143655, "z": 5, "pan": 1, "vis": false }, { - "t": 22.32447548242549, + "t": 21.32447548242549, "z": 5, "pan": 1, "vis": false }, { - "t": 22.22997263818977, + "t": 21.22997263818977, "z": 5, "pan": 1, "vis": false }, { - "t": 22.391814212168764, + "t": 21.391814212168764, "z": 5, "pan": 1, "vis": false }, { - "t": 22.396416297591934, + "t": 21.396416297591934, "z": 5, "pan": 1, "vis": false }, { - "t": 22.21483495930334, + "t": 21.21483495930334, "z": 5, "pan": 1, "vis": false }, { - "t": 22.361081279649238, + "t": 21.361081279649238, "z": 5, "pan": 1, "vis": false }, { - "t": 22.296248465008343, + "t": 21.296248465008343, "z": 5, "pan": 1, "vis": false }, { - "t": 22.48871496586044, + "t": 21.48871496586044, "z": 5, "pan": 1, "vis": false }, { - "t": 22.511654975992236, + "t": 21.511654975992236, "z": 5, "pan": 1, "vis": false }, { - "t": 22.475175028952112, + "t": 21.475175028952112, "z": 5, "pan": 1, "vis": false }, { - "t": 22.551005174530413, + "t": 21.551005174530413, "z": 5, "pan": 1, "vis": false }, { - "t": 22.461285388867847, + "t": 21.461285388867847, "z": 5, "pan": 1, "vis": false }, { - "t": 22.707437677387894, + "t": 21.707437677387894, "z": 5, "pan": 1, "vis": false }, { - "t": 22.686312916833657, + "t": 21.686312916833657, "z": 5, "pan": 1, "vis": false }, { - "t": 22.7452296387756, + "t": 21.7452296387756, "z": 5, "pan": 1, "vis": false }, { - "t": 22.890628532724314, + "t": 21.890628532724314, "z": 5, "pan": 1, "vis": false }, { - "t": 23.162595815410754, + "t": 22.162595815410754, "z": 5, "pan": 1, "vis": false }, { - "t": 23.099558284071378, + "t": 22.099558284071378, "z": 5, "pan": 1, "vis": false }, { - "t": 23.328222481640907, + "t": 22.328222481640907, "z": 5, "pan": 1, "vis": false }, { - "t": 23.240713322193233, + "t": 22.240713322193233, "z": 5, "pan": 1, "vis": false }, { - "t": 23.08964965540118, + "t": 22.08964965540118, "z": 5, "pan": 1, "vis": false }, { - "t": 22.98028771751804, + "t": 21.98028771751804, "z": 5, "pan": 1, "vis": false }, { - "t": 23.033302115936298, + "t": 22.033302115936298, "z": 5, "pan": 1, "vis": false }, { - "t": 22.715503752765, + "t": 21.715503752765, "z": 5, "pan": 1, "vis": false }, { - "t": 22.734187561600535, + "t": 21.734187561600535, "z": 5, "pan": 1, "vis": false }, { - "t": 22.580294003259443, + "t": 21.580294003259443, "z": 5, "pan": 1, "vis": false }, { - "t": 22.661742245516866, + "t": 21.661742245516866, "z": 5, "pan": 1, "vis": false }, { - "t": 22.579062561956817, + "t": 21.579062561956817, "z": 5, "pan": 1, "vis": false }, { - "t": 22.469300966589064, + "t": 21.469300966589064, "z": 5, "pan": 1, "vis": false }, { - "t": 22.442294575300743, + "t": 21.442294575300743, "z": 5, "pan": 1, "vis": false }, { - "t": 22.49695509594644, + "t": 21.49695509594644, "z": 5, "pan": 1, "vis": false }, { - "t": 22.41911393304724, + "t": 21.41911393304724, "z": 5, "pan": 1, "vis": false }, { - "t": 22.271903621617383, + "t": 21.271903621617383, "z": 5, "pan": 1, "vis": false }, { - "t": 22.203257832048855, + "t": 21.203257832048855, "z": 5, "pan": 1, "vis": false }, { - "t": 22.21243970111498, + "t": 21.21243970111498, "z": 5, "pan": 1, "vis": false }, { - "t": 22.25922651669402, + "t": 21.25922651669402, "z": 5, "pan": 1, "vis": false }, { - "t": 22.39057866309626, + "t": 21.39057866309626, "z": 5, "pan": 1, "vis": false }, { - "t": 22.308397116517455, + "t": 21.308397116517455, "z": 5, "pan": 1, "vis": false }, { - "t": 22.411661732103877, + "t": 21.411661732103877, "z": 5, "pan": 1, "vis": false }, { - "t": 22.405795273816388, + "t": 21.405795273816388, "z": 5, "pan": 1, "vis": false }, { - "t": 22.439174915900367, + "t": 21.439174915900367, "z": 5, "pan": 1, "vis": false }, { - "t": 22.3801419325705, + "t": 21.3801419325705, "z": 5, "pan": 1, "vis": false }, { - "t": 22.496917870677525, + "t": 21.496917870677525, "z": 5, "pan": 1, "vis": false }, { - "t": 22.657246962535417, + "t": 21.657246962535417, "z": 5, "pan": 1, "vis": false }, { - "t": 22.61278231415403, + "t": 21.61278231415403, "z": 5, "pan": 1, "vis": false }, { - "t": 22.701925855948733, + "t": 21.701925855948733, "z": 5, "pan": 1, "vis": false }, { - "t": 22.753031163527247, + "t": 21.753031163527247, "z": 5, "pan": 1, "vis": false }, { - "t": 23.10232477482099, + "t": 22.10232477482099, "z": 5, "pan": 1, "vis": false }, { - "t": 23.054271855542414, + "t": 22.054271855542414, "z": 5, "pan": 1, "vis": false }, { - "t": 23.244121773833694, + "t": 22.244121773833694, "z": 5, "pan": 1, "vis": false }, { - "t": 23.460068684119808, + "t": 22.460068684119808, "z": 5, "pan": 1, "vis": false }, { - "t": 23.3448319835848, + "t": 22.3448319835848, "z": 5, "pan": 1, "vis": false }, { - "t": 23.350164823800068, + "t": 22.350164823800068, "z": 5, "pan": 1, "vis": false }, { - "t": 23.131594656010172, + "t": 22.131594656010172, "z": 5, "pan": 1, "vis": false }, { - "t": 22.949174174218918, + "t": 21.949174174218918, "z": 5, "pan": 1, "vis": false }, { - "t": 22.82116031342641, + "t": 21.82116031342641, "z": 5, "pan": 1, "vis": false }, { - "t": 22.741334756349286, + "t": 21.741334756349286, "z": 5, "pan": 1, "vis": false }, { - "t": 22.671717813484644, + "t": 21.671717813484644, "z": 5, "pan": 1, "vis": false }, { - "t": 22.687462212367365, + "t": 21.687462212367365, "z": 5, "pan": 1, "vis": false }, { - "t": 22.498412871010625, + "t": 21.498412871010625, "z": 5, "pan": 1, "vis": false }, { - "t": 22.534091013624447, + "t": 21.534091013624447, "z": 5, "pan": 1, "vis": false }, { - "t": 22.431476265664706, + "t": 21.431476265664706, "z": 5, "pan": 1, "vis": false }, { - "t": 22.369376374082222, + "t": 21.369376374082222, "z": 5, "pan": 1, "vis": false }, { - "t": 22.24656051599304, + "t": 21.24656051599304, "z": 5, "pan": 1, "vis": false }, { - "t": 22.26278456621472, + "t": 21.26278456621472, "z": 5, "pan": 1, "vis": false }, { - "t": 22.31624586329465, + "t": 21.31624586329465, "z": 5, "pan": 1, "vis": false }, { - "t": 22.40617346739395, + "t": 21.40617346739395, "z": 5, "pan": 1, "vis": false @@ -8947,19 +8979,19 @@ } ], "status": [ - 53.4, - 54.5, - 55.6, - 56.7, - 57.7 + 77.4, + 78.5, + 79.6, + 80.7, + 81.7 ], "rapid": [ - 50.4, - 50.62, - 50.839999999999996, - 51.059999999999995, - 51.28, - 51.5, - 51.72 + 72.8, + 73.02, + 73.24, + 73.46, + 73.67999999999999, + 73.89999999999999, + 74.11999999999999 ] } \ No newline at end of file diff --git a/intro-sequence/audio/sfx.py b/intro-sequence/audio/sfx.py new file mode 100644 index 000000000..8b24af41a --- /dev/null +++ b/intro-sequence/audio/sfx.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +"""God's Eye View — First Light (v2): sound design, built to sit under a music track. + +No melody, no beeps. Sub, air, metal, impacts, swells, data texture — a tech +thriller palette that leaves the mids for the score. Everything is synthesised +and placed against audio/cues.json (exported from src/timeline.js). + + python3 audio/sfx.py -> audio/sfx.wav (48 kHz, 24-bit stereo) +""" +import json +import os +import numpy as np +from scipy import signal +from scipy.io import wavfile + +SR = 48000 +HERE = os.path.dirname(os.path.abspath(__file__)) +C = json.load(open(os.path.join(HERE, 'cues.json'))) +T = C['T'] +DUR = C['DURATION'] +N = int(SR * DUR) +rng = np.random.default_rng(20261019) + +B = {k: np.zeros((2, N)) for k in ('bed', 'hits', 'fx', 'ui', 'verb')} + + +def place(dst, sig, t, gain=1.0, pan=0.0, verb=0.0): + if sig.ndim == 1: + p = (pan + 1) * np.pi / 4 + sig = np.vstack([sig * np.cos(p), sig * np.sin(p)]) + i0 = int(round(t * SR)) + if i0 >= N: + return + s0 = max(0, -i0) + i0 = max(0, i0) + n = min(sig.shape[1] - s0, N - i0) + if n <= 0: + return + seg = sig[:, s0:s0 + n] * gain + B[dst][:, i0:i0 + n] += seg + if verb > 0: + B['verb'][:, i0:i0 + n] += seg * verb + + +def tt(d): + return np.arange(int(d * SR)) / SR + + +def lp(fc, o=2): + return signal.butter(o, min(fc, SR * 0.45), 'low', fs=SR, output='sos') + + +def hp(fc, o=2): + return signal.butter(o, max(fc, 10), 'high', fs=SR, output='sos') + + +def bp(lo, hi, o=2): + lo = max(lo, 10) + hi = min(max(hi, lo * 1.05), SR * 0.45) + return signal.butter(o, [lo, hi], 'band', fs=SR, output='sos') + + +def F(x, sos): + return signal.sosfilt(sos, x, axis=-1) + + +def sweep(x, fn, kind='low', block=256): + y = np.zeros_like(x) + zi = None + for b in range(0, len(x), block): + fc = float(fn(b / SR)) + sos = lp(fc) if kind == 'low' else hp(fc) if kind == 'high' else bp(fc * 0.7, fc * 1.35) + if zi is None: + zi = signal.sosfilt_zi(sos) * 0 + y[b:b + block], zi = signal.sosfilt(sos, x[b:b + block], zi=zi) + return y + + +def noise(d): + return rng.standard_normal(int(d * SR)) + + +def env_exp(d, k, a=0.002): + t = tt(d) + return np.minimum(1, t / max(a, 1e-4)) * np.exp(-t * k) + + +def env_ar(d, a, r, c=2.0): + t = tt(d) + return np.minimum(np.clip(t / max(a, 1e-4), 0, 1) ** c, np.clip((d - t) / max(r, 1e-4), 0, 1) ** c) + + +def sine(f, d, ph=0.0): + t = tt(d) + if np.isscalar(f): + return np.sin(2 * np.pi * f * t + ph) + return np.sin(2 * np.pi * np.cumsum(f) / SR + ph) + + +def saw(f, d): + t = tt(d) + ph = f * t if np.isscalar(f) else np.cumsum(f) / SR + return 2 * (ph - np.floor(ph + 0.5)) + + +# ---------------------------------------------------------------- designed sounds +def tick(d=0.006, f=5000, q=0.6): + """A dry, expensive-sounding UI tick: filtered noise, no pitch.""" + return F(noise(d) * env_exp(d, 1200, 0.0002), bp(f * (1 - q / 2), f * (1 + q))) + + +def granule(f=6000): + d = 0.012 + rng.random() * 0.02 + return F(noise(d) * env_ar(d, 0.002, d * 0.8), bp(f * 0.8, f * 1.25)) + + +def sub_boom(d=3.0, f0=55, f1=24, k=1.4): + t = tt(d) + f = f1 + (f0 - f1) * np.exp(-t * 7) + return sine(f, d) * env_exp(d, k, 0.003) + + +def metal(d=3.0, base=180.0, k=1.2): + """Inharmonic struck-metal resonance — the cinematic 'tail'.""" + t = tt(d) + out = np.zeros(len(t)) + for r, a in ((1.0, 1.0), (2.76, 0.6), (5.4, 0.45), (8.93, 0.3), (13.3, 0.2), (1.51, 0.5)): + out += a * np.sin(2 * np.pi * base * r * (1 + 0.002 * rng.standard_normal()) * t + rng.random() * 6) * np.exp(-t * k * (0.6 + r * 0.25)) + return out / 3.0 + + +def crack(d=0.4): + return F(noise(d) * env_exp(d, 18, 0.0005), bp(400, 7000)) + + +def impact(size=1.0, dark=0.0, tail=3.5, base=150): + """Trailer hit: sub drop + body thud + noise crack + metallic tail.""" + d = tail + 0.5 + s = sub_boom(d, 62, 22, 1.1) * 1.0 + body = F(noise(0.5) * env_exp(0.5, 9, 0.001), lp(420)) * 0.9 + out = s + out[: len(body)] += body + c = crack(0.45) * (0.5 - dark * 0.35) + out[: len(c)] += c + m = metal(d, base * (1 - dark * 0.3), 1.0) * 0.28 + out += F(m, lp(4000 - dark * 2500)) + return np.tanh(out * 1.4 * size) * 0.8 + + +def braam(d=4.0, root=36.71, bright=1400, gain=1.0): + """Low, distorted brass-like swell — filtered and dirty, not synthy.""" + acc = np.zeros(int(d * SR)) + for mult in (1, 1.5, 2, 3): + for det in (-0.01, 0, 0.012): + acc += saw(root * mult * (1 + det), d) + acc /= 10 + y = sweep(acc, lambda tc: 120 + bright * min(1, tc / 0.09) * np.exp(-max(0, tc - 0.09) * 1.3)) + y = np.tanh(y * 3.0) * env_ar(d, 0.015, d * 0.85, 1.2) + return y * gain + + +def whoosh(d=1.0, f0=300, f1=4000, peak=0.55, width=0.06): + x = noise(d) + y = sweep(x, lambda tc: f0 * (f1 / f0) ** min(1, tc / d), 'band') + t0 = tt(d) + return y * np.exp(-((t0 / d - peak) ** 2) / width) + + +def swell(d=1.5, f0=200, f1=6000): + """Reverse-swell into a hit.""" + x = noise(d) + y = sweep(x, lambda tc: f0 * (f1 / f0) ** ((tc / d) ** 1.8), 'band') + t0 = tt(d) + m = metal(d, 220, 0.2)[::-1] * 0.25 + return (y + m) * (t0 / d) ** 2.6 + + +def servo(d=0.6, f0=180, f1=420): + t0 = tt(d) + f = f0 + (f1 - f0) * np.sin(np.pi * t0 / d / 2) + x = saw(f, d) + 0.4 * saw(f * 2.01, d) + return F(x, bp(200, 2200)) * env_ar(d, 0.05, 0.12) * 0.5 + + +def glitch(d=0.25): + """Digital stutter: gated, bit-crushed noise and a buzzing carrier.""" + x = noise(d) + gate = (np.sin(2 * np.pi * (35 + rng.random() * 40) * tt(d)) > 0).astype(float) + crushed = np.round(x * 4) / 4 + buzz = np.sign(np.sin(2 * np.pi * (90 + rng.random() * 80) * tt(d))) * 0.3 + return F((crushed * 0.6 + buzz) * gate, bp(300, 9000)) * env_ar(d, 0.002, 0.03) + + +def drone_bed(d, notes=(36.71, 55.0, 73.42), lpf=180): + t0 = tt(d) + x = sum(np.sin(2 * np.pi * f * (1 + 0.0008 * k) * t0 + k) * (0.6 / (k + 1)) for k, f in enumerate(notes)) + x += F(noise(d), lp(lpf)) * 0.25 + return x + + +def tension(d, base=55.0): + """Dissonant cluster, heavily low-passed: dread, not tune.""" + t0 = tt(d) + x = np.zeros(len(t0)) + for r in (1.0, 1.059, 1.5, 2.12, 2.0): + x += saw(base * r * (1 + 0.003 * np.sin(2 * np.pi * 0.07 * t0 * r)), d) + x = F(x / 5, lp(380)) + return x * (0.7 + 0.3 * np.sin(2 * np.pi * 0.11 * t0)) + + +def rotor(d, rate=5.6): + t0 = tt(d) + thump = (0.5 + 0.5 * np.sin(2 * np.pi * rate * t0)) ** 6 + x = F(noise(d), lp(220)) * thump * 1.6 + F(noise(d), bp(900, 3000)) * thump * 0.08 + return x + + +def jet(d): + t0 = tt(d) + whine = sine(2400 + 120 * np.sin(2 * np.pi * 0.2 * t0), d) * 0.04 + sine(3600, d) * 0.015 + roar = F(noise(d), bp(120, 900)) * 0.6 + return roar + whine + + +def wind(d): + a = sweep(noise(d), lambda tc: 260 + 500 * (0.5 + 0.5 * np.sin(tc * 0.9)), 'band') + b = sweep(noise(d), lambda tc: 300 + 600 * (0.5 + 0.5 * np.sin(tc * 0.7 + 1.3)), 'band') + return np.vstack([a, b]) + + +def squelch(d=0.35): + x = F(noise(d), bp(800, 4500)) * env_ar(d, 0.005, 0.06) + return x + + +def heartbeat(t, g=1.0): + for dt, gg, f0 in ((0.0, 1.0, 64), (0.19, 0.55, 54)): + d = 0.7 + t0 = tt(d) + f = 30 + (f0 - 30) * np.exp(-t0 * 28) + place('hits', sine(f, d) * env_exp(d, 7.5, 0.002), t + dt, g * gg, verb=0.2) + + +# =================================================================== the film +t_all = tt(DUR) +# bed: sub + air, riding an intensity curve, silent through the cut +inten = np.interp(t_all, [0, 4, 9, 16, 30, 43, 44, 72, 76.6, T['cut'] - 0.01, T['cut'], T['title'], T['title'] + 3, DUR - 2.5, DUR], + [0, 0.25, 0.4, 0.55, 0.65, 0.7, 0.55, 0.65, 0.75, 1.0, 0, 0, 0.6, 0.5, 0]) +bed = drone_bed(DUR) * inten +B['bed'] += np.vstack([bed, bed]) * 0.32 +airbed = np.vstack([F(noise(DUR), bp(2500, 11000)), F(noise(DUR), bp(2500, 11000))]) * 0.012 * inten +B['bed'] += airbed +# tension cluster under the indexing and the cities +tz = tension(DUR) * np.interp(t_all, [16, 20, 30, 31, 43, 44, 70, 76, 84.4, 84.41], [0, 0.5, 0.6, 0.35, 0.45, 0.6, 0.7, 0.8, 1.0, 0], left=0, right=0) +B['bed'] += np.vstack([tz, tz * 0.95]) * 0.22 + +# ACT 0 — console +hum = (sine(100, DUR) * 0.25 + F(noise(DUR), bp(80, 300)) * 0.05) * np.interp(t_all, [0.4, 1.2, T['collapse'], T['seed']], [0, 1, 1, 0], left=0, right=0) +place('fx', hum, 0, 0.12) +for k in C['boot']: + if k['ch'] != ' ': + place('ui', tick(0.006, 4200 + rng.random() * 2500), k['t'], 0.16, pan=(rng.random() - 0.5) * 0.3) +place('fx', swell(T['seed'] - T['collapse'] + 0.05, 300, 9000), T['collapse'], 0.35) +place('fx', glitch(0.18), T['collapse'], 0.25) +place('hits', impact(0.7, dark=0.6, tail=3.0, base=110), T['seed'], 0.8, verb=0.35) +for bt in (T['beat1'], T['beat2']): + heartbeat(bt, 1.1) +# aperture: servos as the blades retract +place('fx', servo(1.2, 140, 360), T['aperture'], 0.22, -0.3, verb=0.2) +place('fx', servo(1.0, 160, 300), T['aperture'] + 0.25, 0.18, 0.3, verb=0.2) +for i in range(120): + place('ui', tick(0.004, 7000 if i % 10 else 4200), T['dishDraw'] + (i / 120) * (T['dishDone'] - T['dishDraw']), 0.05 if i % 10 else 0.1, pan=np.sin(i / 120 * 6.28) * 0.8) + +# ACT 1 — growth: organic granular crackle densifying +gs, ge = T['growStart'], T['growEnd'] +for _ in range(2400): + x = rng.random() ** 0.45 + tc = gs + x * (ge - gs + 0.3) + k = (np.exp(3 * (tc - gs) / (ge - gs)) - 1) / (np.exp(3) - 1) + if rng.random() > 0.15 + 0.85 * k: + continue + place('fx', granule(2000 + rng.random() * 7000), tc, 0.05 + 0.08 * rng.random() * (0.4 + k), pan=rng.uniform(-0.9, 0.9)) +# radar sweep: a slow low whoosh per revolution +sweep_period = 2 * np.pi / 2.4 +for i in range(int((T['substrate'] - gs) / sweep_period) + 1): + place('fx', whoosh(sweep_period, 120, 900, 0.5, 0.12), gs + i * sweep_period, 0.08, pan=0.0) +place('fx', swell(1.6, 120, 5000), T['substrate'] - 1.6, 0.3) +place('hits', impact(0.9, 0.2, 4.0, 150), T['substrate'], 0.9, verb=0.35) +place('hits', braam(3.5, 36.71, 1100), T['substrate'], 0.35, verb=0.3) + +# ACT 2 — indexing: tiles as data grains, captures as shutter clicks +for tl in C['tiles']: + if not tl['vis'] or tl['t'] > 23.5: + continue + if tl['z'] == 5 and rng.random() > 0.3: + continue + place('ui', granule(3000 + rng.random() * 5000), tl['t'], {3: 0.07, 4: 0.04, 5: 0.025}[tl['z']], pan=tl['pan'] * 0.8) + + +def shutter(t, g=1.0, pan=0.0): + place('fx', tick(0.008, 3800, 0.6), t, 0.45 * g, pan) + place('fx', tick(0.014, 2100, 0.6), t + 0.045, 0.35 * g, pan) + place('fx', F(noise(0.05) * env_exp(0.05, 60), lp(500)), t, 0.25 * g, pan) + + +for cp in C['caps']: + if cp['slow']: + shutter(cp['t'], 1.0, -0.3) + place('fx', servo(0.9, 220, 900), cp['t'] + 0.3, 0.12, -0.3, verb=0.2) + place('fx', whoosh(0.45, 900, 5000, 0.5), cp['t'] + 1.15, 0.16, 0.4) + else: + shutter(cp['t'], 0.5, 0.5) +# the flurry: shutters dissolving into a roar of capture +fl0, fl1 = T['flurry'], T['flurryEnd'] +for i in range(420): + u = rng.random() ** 0.6 + tc = fl0 + u * (fl1 - fl0) + shutter(tc, 0.18 + 0.25 * u, rng.uniform(-0.9, 0.9)) +sw_d = fl1 - fl0 +spin = whoosh(sw_d, 150, 3500, 0.62, 0.05) +place('fx', np.vstack([spin, np.roll(spin, 900)]), fl0, 0.35) +place('fx', swell(1.2, 200, 8000), T['indexed'] - 1.2, 0.35) +place('hits', impact(1.1, 0.0, 4.5, 170), T['indexed'], 1.0, verb=0.4) +place('hits', braam(4.0, 36.71, 1800), T['indexed'], 0.4, verb=0.3) + +# ACT 3 — feeds: each a designed arrival, not a beep +def feed(key, t): + place('ui', tick(0.01, 3000), t, 0.25, -0.5) + place('fx', whoosh(0.5, 2500, 400, 0.3), t - 0.05, 0.12, -0.4) + if key == 'air': + for _ in range(380): + place('ui', tick(0.003, 6000 + rng.random() * 3000), t + rng.random() ** 0.7 * 1.6, 0.04 + rng.random() * 0.04, rng.uniform(-0.9, 0.9)) + place('fx', jet(1.6) * env_ar(1.6, 0.3, 1.0), t, 0.25, 0.3) + elif key == 'sea': + p = sine(820 * np.exp(-tt(2.5) * 0.06), 2.5) * env_exp(2.5, 2.0) + place('fx', F(p, lp(2000)), t + 0.05, 0.12, 0.2, verb=0.9) + place('fx', F(noise(2.0), lp(300)) * env_ar(2.0, 0.6, 1.2), t, 0.25) + elif key == 'mil': + place('fx', squelch(0.3), t + 0.05, 0.25, 0.4) + place('fx', F(saw(48, 1.2), bp(60, 600)) * env_ar(1.2, 0.1, 0.8), t, 0.18) + elif key == 'orbit': + place('fx', whoosh(2.6, 2600, 160, 0.4, 0.1), t + 0.2, 0.3) + place('hits', sub_boom(2.0, 48, 24, 2.0), t + 0.1, 0.5, verb=0.3) + for _ in range(40): + place('ui', granule(7000 + rng.random() * 4000), t + rng.random() * 2.0, 0.03, rng.uniform(-1, 1), verb=0.6) + elif key == 'quake': + for j, dt in enumerate((0.05, 0.45, 0.95)): + place('hits', sub_boom(1.4, 50, 22, 3.0), t + dt, 0.6 - j * 0.12, verb=0.25) + place('fx', F(noise(2.4), lp(90)) * env_ar(2.4, 0.2, 1.6), t, 1.0) + elif key == 'fire': + for _ in range(700): + x = rng.random() ** 1.3 * 2.0 + place('fx', granule(700 + rng.random() * 4000), t + x, (1 - x / 2) * 0.1 * rng.random(), rng.uniform(-0.8, 0.8)) + place('fx', F(noise(2.0), bp(150, 1200)) * env_ar(2.0, 0.05, 1.5), t, 0.08) + elif key == 'wind': + place('fx', wind(2.6) * env_ar(2.6, 0.5, 1.5), t, 0.45) + elif key == 'cable': + buzz = F(saw(60, 1.5) + 0.5 * saw(120.4, 1.5), bp(300, 2500)) * env_ar(1.5, 0.02, 0.9) + place('fx', buzz, t, 0.05, 0.2) + place('fx', glitch(0.2), t, 0.12, -0.2) + + +for fd in C['feeds']: + feed(fd['key'], fd['t']) +for t0, d in ((31.6, 1.8), (33.6, 2.2), (35.0, 2.2)): + place('fx', whoosh(d, 200, 2400, 0.5), t0, 0.16, rng.uniform(-0.5, 0.5)) + +# ACT 4 — descent and the cities +place('fx', swell(1.4, 120, 9000), T['terrain'] - 1.4, 0.45) +place('fx', whoosh(1.2, 4000, 120, 0.7, 0.08), T['terrain'] - 1.0, 0.4) +place('hits', impact(1.0, 0.4, 3.5, 120), T['terrain'], 0.85, verb=0.4) +for c in C['cities']: + t0, t1 = c['t0'], c['t1'] + d = t1 - t0 + if c['slug'] != 'everest': + place('fx', glitch(0.22), t0 - 0.05, 0.35) + place('hits', impact(0.6, 0.5, 2.5, 140), t0, 0.5, verb=0.3) + place('fx', whoosh(0.6, 3000, 300, 0.25), t0 - 0.1, 0.25) + amb = F(noise(d), bp(60, 400)) * env_ar(d, 0.4, 0.4) * 0.5 + place('fx', np.vstack([amb, np.roll(amb, 2000)]), t0, 0.25) + if c['slug'] == 'everest': + place('fx', wind(d) * env_ar(d, 0.6, 0.6), t0, 0.7) + # LiDAR sweep: a soft rising tone per pass + for i in range(int(d / (1 / 0.35 * 1.2 / 1.2)) + 1): + place('fx', F(sine(np.linspace(300, 1200, int(1.2 * SR)), 1.2), bp(200, 1600)) * env_ar(1.2, 0.3, 0.6), t0 + i * 2.86, 0.03, verb=0.4) + elif c['slug'] == 'nyc': + traffic = F(noise(d), bp(80, 700)) * env_ar(d, 0.8, 0.5) + place('fx', np.vstack([traffic, np.roll(traffic, 5000)]), t0, 0.22) + place('fx', rotor(d) * env_ar(d, 1.0, 1.0), t0 + 0.2, 0.3, 0.4) + for j in range(8): + place('fx', tick(0.01, 2400), t0 + 2.2 + j * 0.35, 0.18, 0.7) + place('fx', F(noise(0.1), bp(1500, 7000)) * env_exp(0.1, 25), t0 + 2.2 + j * 0.35, 0.05, 0.7) + elif c['slug'] == 'boston': + for j in range(4): + place('fx', F(noise(1.4), bp(100, 900)) * env_ar(1.4, 0.4, 0.8) * (1 + 0.3 * np.sin(2 * np.pi * 9 * tt(1.4))), t0 + 0.6 + j * 1.0, 0.1, rng.uniform(-0.7, 0.7)) + elif c['slug'] == 'lax': + place('fx', jet(d) * env_ar(d, 0.8, 0.8), t0, 0.35, -0.3) + place('fx', whoosh(2.4, 200, 1400, 0.5, 0.1), t0 + 1.4, 0.3, 0.5) + elif c['slug'] == 'bragg': + place('fx', rotor(d, 4.6) * env_ar(d, 0.8, 0.8), t0 + 0.4, 0.25, -0.4) + for j in range(3): + place('fx', squelch(0.3), t0 + 0.8 + j * 1.3, 0.16, 0.5) + +# the camera wall +wt = T['cctvWall'] +place('hits', impact(0.9, 0.3, 3.0, 160), wt, 0.8, verb=0.3) +place('hits', braam(3.0, 36.71, 1400), wt, 0.3, verb=0.3) +for _ in range(100): + tc = wt + rng.random() ** 0.8 * 0.85 + rng.random() * 0.12 + place('fx', tick(0.012, 2200 + rng.random() * 2000), tc, 0.14, rng.uniform(-0.95, 0.95)) + place('fx', F(noise(0.08), bp(1500, 8000)) * env_exp(0.08, 30), tc, 0.03, rng.uniform(-0.95, 0.95)) +wd = T['cctvOut'] - wt + 0.3 +place('fx', np.vstack([F(noise(wd), bp(800, 6000)), F(noise(wd), bp(800, 6000))]) * env_ar(wd, 0.4, 0.25), wt, 0.035) +for j in range(int((T['cctvOut'] - wt - 0.8) * 7)): + place('ui', tick(0.006, 5200), wt + 0.8 + j / 7, 0.06, rng.uniform(-0.8, 0.8)) +place('fx', whoosh(0.6, 5000, 150, 0.7), T['cctvOut'] - 0.1, 0.4) +place('hits', impact(0.9, 0.5, 3.0, 120), T['cctvOut'] + 0.4, 0.7, verb=0.35) + +# ACT 5 — saturation: rising pressure, the breach, the state +sat0, cut = T['saturate'], T['cut'] +sd = cut - sat0 +t0s = tt(sd) +pos = 0.45 * t0s + 0.06 * t0s ** 2 +sh = np.zeros(len(t0s)) +for k in range(9): + o = (k + pos) % 9 + a = np.exp(-((o - 4.5) ** 2) / (2 * 1.8 ** 2)) + sh += np.sin(2 * np.pi * np.cumsum(30 * 2 ** o) / SR) * a +sh = F(sh / 9 * 2, lp(2200)) * env_ar(sd, 2.0, 0.01, 1.3) +place('fx', sh, sat0, 0.3, verb=0.2) +place('fx', swell(sd, 120, 9000), sat0, 0.3) +# pulse of low hits accelerating toward the breach +tk = sat0 + 0.5 +gap = 0.9 +while tk < T['dishBreak'] - 0.2: + place('hits', sub_boom(0.9, 58, 30, 4.0), tk, 0.35 + 0.35 * (tk - sat0) / sd) + place('fx', F(noise(0.12), bp(500, 4000)) * env_exp(0.12, 30), tk, 0.08) + tk += gap + gap = max(0.22, gap * 0.86) +for st in C['status']: + for j in range(16): + place('ui', tick(0.005, 4500 + rng.random() * 1500), st + j * 0.026, 0.1, 0.4) +for _ in range(300): + tc = T['twin'] + rng.random() ** 0.7 * (T['dishBreak'] - T['twin']) + place('ui', granule(5000 + rng.random() * 5000), tc, 0.025, rng.uniform(-1, 1), verb=0.3) +db = T['dishBreak'] +place('hits', impact(1.25, 0.0, 5.0, 190), db, 1.1, verb=0.45) +place('hits', braam(3.0, 36.71, 2600), db, 0.45, verb=0.4) +for _ in range(200): + tc = db + rng.random() ** 2.2 * 1.1 + f = 2500 + rng.random() * 7000 + g = F(noise(0.25), bp(f * 0.97, f * 1.03)) * env_exp(0.25, 10 + rng.random() * 25) + place('fx', g, tc, 0.16 * (1 - (tc - db) / 1.2), rng.uniform(-1, 1), verb=0.3) +place('hits', impact(1.0, 0.3, 4.0, 110), T['omni'], 0.8, verb=0.45) + +# ACT 6 — title: low bloom, the eye, the word +ti = T['title'] +d = DUR - ti +warm = drone_bed(d, (36.71, 55.0, 73.42, 110.0), 140) * np.interp(tt(d), [0, 0.8, 2.0, d - 3.0, d], [0, 0.4, 0.7, 0.6, 0]) +place('bed', warm, ti, 0.3) +place('fx', swell(0.9, 400, 7000), T['eyeOpen'] - 0.9, 0.2) +place('hits', impact(0.8, 0.6, 5.0, 90), T['eyeOpen'], 0.6, verb=0.5) +place('fx', metal(6.0, 220, 0.5) * 0.4, T['eyeOpen'], 0.12, verb=0.7) +for j in range(36): + place('ui', tick(0.006, 3500 + rng.random() * 4000), T['wordmark'] + j * 0.024, 0.07, rng.uniform(-0.4, 0.4)) +place('hits', impact(1.0, 0.4, 6.5, 100), T['wordmark'] + 0.85, 0.8, verb=0.55) +place('hits', braam(6.0, 36.71, 900), T['wordmark'] + 0.85, 0.35, verb=0.5) +for j in range(11): + place('ui', tick(0.008, 4000), T['tagline'] + j * 0.08, 0.1) +place('fx', metal(6.0, 330, 0.45) * 0.35, T['tagline'] + 0.9, 0.1, verb=0.8) + +# =================================================================== reverb, mix, master +def ir(rt=3.6, pre=0.03): + n = int((rt + pre) * SR) + t0 = np.arange(n) / SR + out = np.zeros((2, n)) + for c in range(2): + x = rng.standard_normal(n) * np.exp(-6.9 * t0 / rt) + l2 = F(x, lp(2200)) + m = np.clip(t0 / 1.2, 0, 1) + out[c] = x * (1 - m) + l2 * m + out[c, : int(pre * SR)] = 0 + return out / np.sqrt(np.sum(out ** 2) / 2) + + +R = ir() +wet = np.vstack([signal.fftconvolve(B['verb'][c], R[c])[:N] for c in range(2)]) +dip = np.ones(N) +for ht, depth in ((T['substrate'], 0.3), (T['indexed'], 0.25), (T['terrain'], 0.3), (T['cctvWall'], 0.4), (T['dishBreak'], 0.2), (T['wordmark'] + 0.85, 0.4)): + a, b, c = int((ht - 0.28) * SR), int((ht - 0.03) * SR), int((ht + 0.5) * SR) + dip[a:b] = np.minimum(dip[a:b], np.linspace(1, depth, b - a)) + dip[b:c] = np.minimum(dip[b:c], np.linspace(depth, 1, c - b) ** 0.7) +mix = (B['bed'] + B['fx'] * 0.9 + B['ui'] * 0.8) * dip + B['hits'] * 1.2 + wet * 0.3 +mix = F(mix, hp(22)) + + +def stutter(x, a, b, sl=0.04): + ia, ib, s = int(a * SR), int(b * SR), int(sl * SR) + src = x[:, ia:ia + s].copy() + for i in range(ia, ib, s): + n = min(s, ib - i) + x[:, i:i + n] = src[:, :n] * (0.9 if ((i - ia) // s) % 2 == 0 else 0.55) + return x + + +mix = stutter(mix, cut - 0.22, cut) +ic, it = int(cut * SR), int(T['title'] * SR) +f = int(0.004 * SR) +mix[:, ic - f:ic] *= np.linspace(1, 0, f) +mix[:, ic:it] = 0 + +# gentle glue + ceiling +p = np.sqrt(np.convolve(np.mean(mix ** 2, axis=0), np.ones(4000) / 4000, mode='same') + 1e-12) +thr = 10 ** (-16 / 20) +g = np.where(p > thr, (thr * (p / thr) ** (1 / 1.8)) / p, 1.0) +g = signal.sosfiltfilt(lp(8), g) +mix *= g +mix = np.tanh(mix * 1.5) / 1.5 +look = int(0.004 * SR) +pk = np.lib.stride_tricks.sliding_window_view(np.pad(np.max(np.abs(mix), axis=0), (0, look)), look + 1).max(axis=1)[:N] +ceil = 10 ** (-1.2 / 20) +lg = signal.sosfiltfilt(lp(60), np.minimum(1.0, ceil / np.maximum(pk, 1e-9))) +mix *= np.minimum(lg, 1.0) +mix *= ceil / max(1e-9, np.max(np.abs(mix))) +out = os.path.join(HERE, 'sfx.wav') +wavfile.write(out, SR, (mix.T * (2 ** 31 - 1)).astype(np.int32)) +print('wrote', out, DUR, 's') diff --git a/intro-sequence/src/city.js b/intro-sequence/src/city.js new file mode 100644 index 000000000..e15dad663 --- /dev/null +++ b/intro-sequence/src/city.js @@ -0,0 +1,915 @@ +import * as THREE from 'three'; +import { mulberry32, clamp } from './timeline.js'; + +// City scenes in a local ENU frame (data in metres, scene units in km; +// three.js x = east, y = up, z = -north). One engine renders every location: +// Esri ground, OSM buildings that rise as the scan passes, road ribbons that +// carry modelled traffic coloured by congestion, cameras, aircraft, transit, +// airports, restricted areas and terrain. + +const KM = 0.001; + +async function getJSON(p) { + try { + const r = await fetch(p); + return r.ok ? await r.json() : null; + } catch { + return null; + } +} +async function getBin(p) { + try { + const r = await fetch(p); + return r.ok ? await r.arrayBuffer() : null; + } catch { + return null; + } +} +function loadTex(p) { + return new Promise((res) => + new THREE.TextureLoader().load( + p, + (t) => { + t.colorSpace = THREE.SRGBColorSpace; + t.anisotropy = 8; + res(t); + }, + undefined, + () => res(null), + ), + ); +} + +export async function loadCity(slug) { + const base = `city/${slug}/`; + const [meta, buildings, roads, extra, cameras, air, transit, vehicles, aeroway, military, peaks, elevMeta, elev, ground] = await Promise.all([ + getJSON(base + 'meta.json'), + getJSON(base + 'buildings.json'), + getJSON(base + 'roads.json'), + getJSON(base + 'extra.json'), + getJSON(base + 'cameras.json'), + getJSON(base + 'air.json'), + getJSON(base + 'transit.json'), + getJSON(base + 'vehicles.json'), + getJSON(base + 'aeroway.json'), + getJSON(base + 'military.json'), + getJSON(base + 'peaks.json'), + getJSON(base + 'elev_meta.json'), + getBin(base + 'elev.f32'), + loadTex(base + 'ground.jpg'), + ]); + return { slug, meta, buildings, roads, extra, cameras, air, transit, vehicles, aeroway, military, peaks, elevMeta, elev: elev ? new Float32Array(elev) : null, ground }; +} + +// ---------------------------------------------------------------- shared shader bits +const HASH = /* glsl */ ` + float h11(float n) { return fract(sin(n * 127.1) * 43758.5453); } + float h21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +`; + +function congestionColor() { + return /* glsl */ ` + vec3 congCol(float c) { + vec3 g = vec3(0.10, 1.00, 0.45); + vec3 y = vec3(1.00, 0.80, 0.10); + vec3 r = vec3(1.00, 0.16, 0.12); + return c < 0.5 ? mix(g, y, c * 2.0) : mix(y, r, (c - 0.5) * 2.0); + } + `; +} + +// ---------------------------------------------------------------- ground +function makeGround(d, half, elevGeo) { + const size = 2 * half * KM; + const geo = elevGeo || new THREE.PlaneGeometry(size, size, 1, 1).rotateX(-Math.PI / 2); + const mat = new THREE.ShaderMaterial({ + uniforms: { + uTex: { value: d.ground }, + uReveal: { value: 0 }, + uT: { value: 0 }, + uHalf: { value: half * KM }, + uNight: { value: 0.55 }, + uTerrain: { value: elevGeo ? 1 : 0 }, + uScan: { value: -10 }, + uFade: { value: 1 }, + }, + vertexShader: /* glsl */ ` + attribute float aElev; + varying vec2 vUv; + varying vec3 vP; + varying float vElev; + varying vec3 vN; + void main() { + vUv = uv; + vP = position; + vElev = aElev; + vN = normal; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + uniform sampler2D uTex; + uniform float uReveal, uT, uHalf, uNight, uTerrain, uScan, uFade; + varying vec2 vUv; + varying vec3 vP; + varying float vElev; + varying vec3 vN; + void main() { + vec3 img = texture2D(uTex, vUv).rgb; + float l = dot(img, vec3(0.3, 0.59, 0.11)); + // night grade: cool, dim, contrasty — real pixels, but the city's lights do the talking + vec3 night = pow(mix(vec3(l), img, 0.35), vec3(1.7)) * vec3(0.5, 0.7, 0.95) * 0.56; + float r = length(vP.xz) / uHalf; + float rev = smoothstep(uReveal, uReveal - 0.08, r); + // tactical grid: 100 m, brighter every km + vec2 g = vP.xz * 1000.0; + vec2 fw = fwidth(g); + vec2 gl = abs(fract(g / 100.0 - 0.5) - 0.5) * 100.0 / fw; + float grid = 1.0 - smoothstep(0.0, 1.0, min(gl.x, gl.y)); + vec2 gk = abs(fract(g / 1000.0 - 0.5) - 0.5) * 1000.0 / fw; + float gridK = 1.0 - smoothstep(0.0, 1.3, min(gk.x, gk.y)); + vec3 col = night * rev; + col += vec3(0.0, 0.55, 0.7) * (grid * 0.05 + gridK * 0.14) * (0.4 + 0.6 * rev); + // the scan front + float ring = exp(-pow((r - uReveal) * 60.0, 2.0)); + col += vec3(0.2, 0.9, 1.0) * ring * 0.6; + if (uTerrain > 0.5) { + // terrain: sunlit relief, 100 m contours, a LiDAR line sweeping across + vec3 L = normalize(vec3(-0.5, 0.7, 0.4)); + float sh = clamp(dot(normalize(vN), L), 0.0, 1.0); + vec3 tg = pow(mix(vec3(l), img, 0.5), vec3(1.35)); + col = tg * (0.08 + 0.62 * sh) * vec3(0.62, 0.78, 1.0) * rev; + float ce = vElev / 100.0; + float cw = fwidth(ce); + float cont = 1.0 - smoothstep(0.0, 1.2 * cw, abs(fract(ce - 0.5) - 0.5)); + float major = 1.0 - smoothstep(0.0, 1.2 * fwidth(vElev / 500.0), abs(fract(vElev / 500.0 - 0.5) - 0.5)); + col += vec3(0.1, 0.8, 1.0) * (cont * 0.16 + major * 0.5) * rev; + float sweep = exp(-pow((vP.x - uScan) * 18.0, 2.0)); + col += vec3(0.4, 1.0, 1.0) * sweep * 0.9; + col += vec3(0.0, 0.6, 0.8) * smoothstep(uScan, uScan - 3.0, vP.x) * 0.03 * step(vP.x, uScan); + } + float edge = smoothstep(1.0, 0.82, max(abs(vP.x), abs(vP.z)) / uHalf); + gl_FragColor = vec4(col * edge * uFade, 1.0); + } + `, + }); + // the ground never writes depth: it is the base layer, and road ribbons a + // metre above it would otherwise lose the depth test at city distances + if (!elevGeo) mat.depthWrite = false; + const mesh = new THREE.Mesh(geo, mat); + mesh.renderOrder = -1; + return mesh; +} + +function makeTerrainGeo(d, half) { + const n = d.elevMeta?.size || 1024; + const seg = 384; + const size = 2 * half * KM; + const geo = new THREE.PlaneGeometry(size, size, seg, seg).rotateX(-Math.PI / 2); + const pos = geo.getAttribute('position'); + const uv = geo.getAttribute('uv'); + const el = new Float32Array(pos.count); + for (let i = 0; i < pos.count; i++) { + const u = uv.getX(i); + const v = uv.getY(i); + const px = Math.min(n - 1, Math.max(0, Math.round(u * (n - 1)))); + const py = Math.min(n - 1, Math.max(0, Math.round((1 - v) * (n - 1)))); + const h = d.elev[py * n + px]; + el[i] = h; + pos.setY(i, h * KM); + } + geo.setAttribute('aElev', new THREE.BufferAttribute(el, 1)); + geo.computeVertexNormals(); + return geo; +} + +// ---------------------------------------------------------------- buildings +function makeBuildings(list) { + const P = []; + const UV = []; + const A = []; // height m, edge length m, dist km, rand + const idx = []; + let vi = 0; + const rnd = mulberry32(5); + const rings = []; + for (const b of list) { + const h = Math.max(4, Math.min(560, b[0] || 10)); + const pts = []; + for (let i = 2; i < b.length - 1; i += 2) pts.push([b[i], b[i + 1]]); + if (pts.length < 3) continue; + let cx = 0; + let cy = 0; + for (const p of pts) { + cx += p[0]; + cy += p[1]; + } + cx /= pts.length; + cy /= pts.length; + const dist = Math.hypot(cx, cy) * KM; + const r = rnd(); + // walls + for (let i = 0; i < pts.length; i++) { + const [x1, y1] = pts[i]; + const [x2, y2] = pts[(i + 1) % pts.length]; + const L = Math.hypot(x2 - x1, y2 - y1); + if (L < 0.3) continue; + P.push(x1 * KM, 0, -y1 * KM, x2 * KM, 0, -y2 * KM, x2 * KM, h * KM, -y2 * KM, x1 * KM, h * KM, -y1 * KM); + UV.push(0, 0, 1, 0, 1, 1, 0, 1); + for (let k = 0; k < 4; k++) A.push(h, L, dist, r); + idx.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3); + vi += 4; + } + // roof + const contour = pts.map((p) => new THREE.Vector2(p[0], p[1])); + let tris = []; + try { + tris = THREE.ShapeUtils.triangulateShape(contour, []); + } catch { + tris = []; + } + const base = vi; + for (const p of pts) { + P.push(p[0] * KM, h * KM, -p[1] * KM); + UV.push(-1, 1); // u < 0 marks roof + A.push(h, 0, dist, r); + vi++; + } + for (const t of tris) idx.push(base + t[0], base + t[2], base + t[1]); + rings.push({ pts, h, dist }); + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(UV, 2)); + geo.setAttribute('aB', new THREE.Float32BufferAttribute(A, 4)); + geo.setIndex(vi > 65535 ? new THREE.Uint32BufferAttribute(idx, 1) : new THREE.Uint16BufferAttribute(idx, 1)); + const mat = new THREE.ShaderMaterial({ + uniforms: { uReveal: { value: 0 }, uT: { value: 0 }, uAlpha: { value: 1 }, uTint: { value: new THREE.Color('#38e8ff') } }, + vertexShader: /* glsl */ ` + attribute vec4 aB; + uniform float uReveal; + varying vec2 vUv; + varying vec4 vB; + varying vec3 vW; + varying float vRise; + void main() { + vUv = uv; + vB = aB; + float k = clamp((uReveal - aB.z) / 0.35, 0.0, 1.0); + float rise = 1.0 - pow(1.0 - k, 3.0); + vRise = k; + vec3 p = position; + p.y *= rise; + vec4 wp = modelMatrix * vec4(p, 1.0); + vW = wp.xyz; + gl_Position = projectionMatrix * viewMatrix * wp; + } + `, + fragmentShader: /* glsl */ ` + ${HASH} + uniform float uReveal, uT, uAlpha; + uniform vec3 uTint; + varying vec2 vUv; + varying vec4 vB; + varying vec3 vW; + varying float vRise; + void main() { + if (vRise <= 0.0) discard; + float h = vB.x; + float L = vB.y; + bool roof = vUv.x < -0.5; + vec3 N = normalize(cross(dFdx(vW), dFdy(vW))); + float lit = 0.35 + 0.65 * abs(dot(N, normalize(vec3(0.4, 0.8, 0.3)))); + float tall = smoothstep(30.0, 250.0, h); + vec3 base = mix(vec3(0.010, 0.016, 0.026), vec3(0.016, 0.032, 0.05), tall) * lit; + vec3 col = base; + float fresh = exp(-max(uReveal - vB.z, 0.0) * 5.0); + if (roof) { + col = base * 1.3 + uTint * 0.02; + } else { + // floors and windows + float fy = vUv.y * h / 3.4; + float fx = vUv.x * L / 3.2; + vec2 cell = floor(vec2(fx, fy)); + vec2 f = fract(vec2(fx, fy)); + float win = step(0.25, f.x) * step(f.x, 0.8) * step(0.3, f.y) * step(f.y, 0.75); + float on = step(0.9, h21(cell + vB.w * 91.0)); + float warm = h21(cell * 1.7 + 3.0); + vec3 wc = mix(vec3(1.0, 0.75, 0.45), vec3(0.6, 0.9, 1.0), step(0.7, warm)); + float flick = 0.85 + 0.15 * sin(uT * 2.0 + h21(cell) * 40.0); + col += wc * win * on * 0.32 * flick * smoothstep(0.0, 0.1, vRise); + // edges: crown line, vertical corners, base + float wy = fwidth(vUv.y); + float wx = fwidth(vUv.x); + float crown = 1.0 - smoothstep(0.0, wy * 1.5, 1.0 - vUv.y); + float corner = 1.0 - smoothstep(0.0, wx * 1.5, min(vUv.x, 1.0 - vUv.x)); + col += uTint * (crown * (0.08 + tall * 0.9) + corner * (0.03 + tall * 0.12)) * (0.6 + fresh * 2.5); + // the scan passing up the facade as it rises + col += uTint * fresh * 0.25 * smoothstep(0.2, 1.0, vUv.y); + } + gl_FragColor = vec4(col * uAlpha, 1.0); + } + `, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.frustumCulled = false; + return { mesh, count: rings.length, rings }; +} + +// ---------------------------------------------------------------- roads + traffic +// Congestion is modelled, not measured: road class, distance to the core, and +// a slow per-road noise. Labelled MODELED in the HUD. +function makeRoads(list, half, { tint = null, inside = null, scale = 1 } = {}) { + const W = [34, 24, 20, 16, 11, 7]; + const P = []; + const A = []; // arc m, side, cong, oneway + const B = []; // class, rnd, width + const idx = []; + let vi = 0; + const rnd = mulberry32(17); + let segs = 0; + for (const r of list) { + const cls = r[0]; + const one = r[1] ? 1 : 0; + const pts = []; + for (let i = 3; i < r.length - 1; i += 2) pts.push([r[i], r[i + 1]]); + if (pts.length < 2) continue; + if (inside && !inside(pts[0][0], pts[0][1])) continue; + const w = W[cls] ?? 6; + const cx = pts[Math.floor(pts.length / 2)]; + const core = Math.exp(-Math.hypot(cx[0], cx[1]) / (half * 0.55)); + const base = [0.55, 0.5, 0.42, 0.35, 0.22, 0.3][cls] ?? 0.3; + const rr = rnd(); + const cong = clamp(base * 0.55 + core * 0.55 + (rr - 0.5) * 0.95, 0, 1); + let arc = 0; + for (let i = 0; i < pts.length - 1; i++) { + const [x1, y1] = pts[i]; + const [x2, y2] = pts[i + 1]; + const L = Math.hypot(x2 - x1, y2 - y1); + if (L < 0.5) continue; + const nx = -(y2 - y1) / L; + const ny = (x2 - x1) / L; + const hw = w / 2; + const y = 0.6 + cls * 0.02; + const v = [ + [x1 - nx * hw, y1 - ny * hw, -1, arc], + [x1 + nx * hw, y1 + ny * hw, 1, arc], + [x2 + nx * hw, y2 + ny * hw, 1, arc + L], + [x2 - nx * hw, y2 - ny * hw, -1, arc + L], + ]; + for (const [x, yy, s, a] of v) { + P.push(x * KM, y * KM, -yy * KM); + A.push(a, s, cong, one); + B.push(cls, rr, w); + } + idx.push(vi, vi + 1, vi + 2, vi, vi + 2, vi + 3); + vi += 4; + arc += L; + segs++; + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3)); + geo.setAttribute('aA', new THREE.Float32BufferAttribute(A, 4)); + geo.setAttribute('aC', new THREE.Float32BufferAttribute(B, 3)); + geo.setIndex(new THREE.Uint32BufferAttribute(idx, 1)); + const mat = new THREE.ShaderMaterial({ + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { + uT: { value: 0 }, + uReveal: { value: 0 }, + uHalf: { value: half * KM }, + uAlpha: { value: 1 }, + uTint: { value: tint ? new THREE.Color(tint) : new THREE.Color(0, 0, 0) }, + uUseTint: { value: tint ? 1 : 0 }, + uScale: { value: scale }, + }, + vertexShader: /* glsl */ ` + attribute vec4 aA; + attribute vec3 aC; + varying vec4 vA; + varying vec3 vC; + varying float vR; + void main() { + vA = aA; + vC = aC; + vR = length(position.xz); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */ ` + ${HASH} + ${congestionColor()} + uniform float uT, uReveal, uHalf, uAlpha, uUseTint, uScale; + uniform vec3 uTint; + varying vec4 vA; + varying vec3 vC; + varying float vR; + void main() { + float r = vR / uHalf; + float k = uReveal - r; + if (k < 0.0) discard; + float cong = vA.z; + float side = vA.y; + float dir = vA.w > 0.5 ? 1.0 : (side > 0.0 ? 1.0 : -1.0); + vec3 cc = mix(congCol(cong), uTint, uUseTint); + // ribbon cross-profile: soft edges, dark median + float a = abs(side); + float body = smoothstep(1.0, 0.7, a); + // cars: dashes flowing along each lane, slower and denser when jammed + float speed = mix(16.0, 2.5, cong) * 7.0 * uScale; + float spacing = mix(70.0, 14.0, cong); + float lane = vA.w > 0.5 ? 1.0 : smoothstep(0.05, 0.3, a) * smoothstep(0.98, 0.7, a); + float ph = (vA.x - dir * uT * speed) / spacing + vC.y * 13.0 + (side > 0.0 ? 0.37 : 0.0); + float fp = fract(ph); + float car = smoothstep(0.0, 0.04, fp) * (1.0 - smoothstep(0.08, 0.22, fp)); + float present = step(0.25 + 0.5 * (1.0 - cong), h11(floor(ph) + vC.y * 77.0)); + float head = car * present * lane; + vec3 carCol = dir > 0.0 ? vec3(1.0, 0.95, 0.85) : vec3(1.0, 0.25, 0.2); + vec3 col = cc * body * (0.2 + 0.12 * cong) + mix(cc, carCol, 0.22) * head * 1.25; + float fresh = exp(-k * 25.0); + col += cc * fresh * 0.8 * body; + gl_FragColor = vec4(col * uAlpha, 1.0); + } + `, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.frustumCulled = false; + return { mesh, segs }; +} + +// ---------------------------------------------------------------- lines (generic, glowing) +function makePolylines(lines, { color = '#38e8ff', y = 1, width = 0, alpha = 1, colors = null } = {}) { + const P = []; + const C = []; + const A = []; + for (let li = 0; li < lines.length; li++) { + const l = lines[li]; + const col = new THREE.Color(colors ? colors[li] : color); + let arc = 0; + for (let i = 0; i < l.length - 1; i++) { + const [x1, y1, z1 = y] = l[i]; + const [x2, y2, z2 = y] = l[i + 1]; + const L = Math.hypot(x2 - x1, y2 - y1); + P.push(x1 * KM, z1 * KM, -y1 * KM, x2 * KM, z2 * KM, -y2 * KM); + C.push(col.r, col.g, col.b, col.r, col.g, col.b); + A.push(arc, arc + L); + arc += L; + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(P, 3)); + geo.setAttribute('color', new THREE.Float32BufferAttribute(C, 3)); + geo.setAttribute('aArc', new THREE.Float32BufferAttribute(A, 1)); + const mat = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + vertexColors: true, + uniforms: { uT: { value: 0 }, uAlpha: { value: alpha }, uDraw: { value: 1e9 }, uPulse: { value: 0 } }, + vertexShader: /* glsl */ ` + attribute float aArc; + varying float vArc; + varying vec3 vCol; + void main() { vArc = aArc; vCol = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } + `, + fragmentShader: /* glsl */ ` + uniform float uT, uAlpha, uDraw, uPulse; + varying float vArc; + varying vec3 vCol; + void main() { + if (vArc > uDraw) discard; + float p = uPulse > 0.0 ? pow(fract(vArc / 900.0 - uT * 0.8), 8.0) * 2.0 : 0.0; + gl_FragColor = vec4(vCol * (0.7 + p) * uAlpha, 1.0); + } + `, + }); + return new THREE.LineSegments(geo, mat); +} + +// glowing point sprites updated from the CPU +function makePoints(n, { size = 6, color = '#ffffff' } = {}) { + const geo = new THREE.BufferGeometry(); + const pos = new Float32Array(n * 3); + const col = new Float32Array(n * 3); + const sz = new Float32Array(n).fill(1); + const c = new THREE.Color(color); + for (let i = 0; i < n; i++) col.set([c.r, c.g, c.b], i * 3); + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(col, 3)); + geo.setAttribute('aS', new THREE.BufferAttribute(sz, 1)); + const mat = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + depthTest: false, + blending: THREE.AdditiveBlending, + vertexColors: true, + uniforms: { uSize: { value: size }, uAlpha: { value: 1 }, uPx: { value: 1 } }, + vertexShader: /* glsl */ ` + attribute float aS; + uniform float uSize, uPx; + varying vec3 vCol; + void main() { vCol = color; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); gl_PointSize = uSize * aS * uPx; } + `, + fragmentShader: /* glsl */ ` + uniform float uAlpha; + varying vec3 vCol; + void main() { + float r = length(gl_PointCoord - 0.5) * 2.0; + float a = smoothstep(1.0, 0.0, r); + float core = smoothstep(0.35, 0.0, r); + gl_FragColor = vec4((vCol * a * 0.8 + vec3(1.0) * core) * uAlpha, 1.0); + } + `, + }); + const pts = new THREE.Points(geo, mat); + pts.frustumCulled = false; + return { pts, pos, col, sz, geo }; +} + +// ---------------------------------------------------------------- the engine +export function buildCity(d, opts = {}) { + const half = d.meta?.half || 3500; + const scene = new THREE.Scene(); + const group = new THREE.Group(); + scene.add(group); + const out = { scene, d, half, labels: [], stats: {} }; + + const terrainGeo = d.elev ? makeTerrainGeo(d, half) : null; + const ground = makeGround(d, half, terrainGeo); + group.add(ground); + out.ground = ground; + if (d.elev) { + out.elevAt = (x, y) => { + const n = d.elevMeta?.size || 1024; + const px = Math.round(((x + half) / (2 * half)) * (n - 1)); + const py = Math.round(((half - y) / (2 * half)) * (n - 1)); + return d.elev[clamp(py, 0, n - 1) * n + clamp(px, 0, n - 1)] || 0; + }; + } + + if (d.buildings?.b?.length) { + const bl = makeBuildings(d.buildings.b); + group.add(bl.mesh); + out.buildings = bl; + out.stats.buildings = bl.count; + } + + // military polygons: roads inside get the amber treatment + let insideMil = null; + if (d.military?.military?.length) { + const polys = d.military.military.map((m) => { + const pts = []; + for (let i = 1; i < m.length - 1; i += 2) pts.push([m[i], m[i + 1]]); + return { name: m[0], pts }; + }); + out.milPolys = polys; + insideMil = (x, y) => polys.some((p) => pip(x, y, p.pts)); + } + + if (d.roads?.roads?.length) { + const rd = makeRoads(d.roads.roads, half); + group.add(rd.mesh); + out.roads = rd; + out.stats.roads = rd.segs; + if (insideMil) { + const conv = makeRoads(d.roads.roads, half, { tint: '#ffb547', inside: insideMil, scale: 0.6 }); + conv.mesh.material.uniforms.uAlpha.value = 0.9; + group.add(conv.mesh); + out.convoy = conv; + } + } + + // restricted areas: translucent curtains with scanning bands + if (out.milPolys) { + const P = []; + const U = []; + for (const p of out.milPolys) { + let arc = 0; + for (let i = 0; i < p.pts.length; i++) { + const [x1, y1] = p.pts[i]; + const [x2, y2] = p.pts[(i + 1) % p.pts.length]; + const L = Math.hypot(x2 - x1, y2 - y1); + const H = 0.35; + P.push(x1 * KM, 0, -y1 * KM, x2 * KM, 0, -y2 * KM, x2 * KM, H, -y2 * KM, x1 * KM, 0, -y1 * KM, x2 * KM, H, -y2 * KM, x1 * KM, H, -y1 * KM); + U.push(arc, 0, arc + L, 0, arc + L, 1, arc, 0, arc + L, 1, arc, 1); + arc += L; + } + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3)); + g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2)); + const m = new THREE.ShaderMaterial({ + transparent: true, + depthWrite: false, + side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, + uniforms: { uT: { value: 0 }, uAlpha: { value: 0 } }, + vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`, + fragmentShader: /* glsl */ ` + uniform float uT, uAlpha; + varying vec2 vUv; + void main() { + float band = pow(fract(vUv.y * 6.0 - uT * 0.6), 6.0); + float top = 1.0 - smoothstep(0.0, 0.04, 1.0 - vUv.y); + float fade = 1.0 - vUv.y; + float hatch = step(0.5, fract((vUv.x / 60.0 + vUv.y * 4.0))); + vec3 c = vec3(1.0, 0.62, 0.18) * (fade * 0.10 + band * 0.25 + top * 0.8 + hatch * fade * 0.03); + gl_FragColor = vec4(c * uAlpha, 1.0); + } + `, + }); + out.curtain = new THREE.Mesh(g, m); + out.curtain.frustumCulled = false; + group.add(out.curtain); + } + + // airport: runways, approach strobes, taxiways + const aw = d.aeroway || d.military; + if (aw?.runways?.length) { + const P = []; + const U = []; + const strobes = []; + for (const rw of aw.runways) { + const w = Math.max(30, rw[0] || 45); + const pts = []; + for (let i = 2; i < rw.length - 1; i += 2) pts.push([rw[i], rw[i + 1]]); + if (pts.length < 2) continue; + const [x1, y1] = pts[0]; + const [x2, y2] = pts[pts.length - 1]; + const L = Math.hypot(x2 - x1, y2 - y1); + const ux = (x2 - x1) / L; + const uy = (y2 - y1) / L; + const nx = -uy * w / 2; + const ny = ux * w / 2; + const q = [[x1 - nx, y1 - ny, 0, 0], [x1 + nx, y1 + ny, 1, 0], [x2 + nx, y2 + ny, 1, L], [x1 - nx, y1 - ny, 0, 0], [x2 + nx, y2 + ny, 1, L], [x2 - nx, y2 - ny, 0, L]]; + for (const [x, y, s, a] of q) { + P.push(x * KM, 0.0012, -y * KM); + U.push(s, a); + } + // approach lighting: 900 m of strobes off each threshold + for (const [sx, sy, dx, dy] of [[x1, y1, -ux, -uy], [x2, y2, ux, uy]]) { + for (let k = 1; k <= 30; k++) strobes.push([sx + dx * k * 30, sy + dy * k * 30, k]); + } + out.labels.push({ kind: 'runway', x: (x1 + x2) / 2, y: (y1 + y2) / 2, text: `RWY ${rw[1] || ''}`.trim() }); + } + const g = new THREE.BufferGeometry(); + g.setAttribute('position', new THREE.Float32BufferAttribute(P, 3)); + g.setAttribute('uv', new THREE.Float32BufferAttribute(U, 2)); + const m = new THREE.ShaderMaterial({ + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + blending: THREE.AdditiveBlending, + uniforms: { uT: { value: 0 }, uAlpha: { value: 1 } }, + vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`, + fragmentShader: /* glsl */ ` + uniform float uT, uAlpha; + varying vec2 vUv; + void main() { + float edge = 1.0 - smoothstep(0.0, 0.06, min(vUv.x, 1.0 - vUv.x)); + float cl = (1.0 - smoothstep(0.0, 0.03, abs(vUv.x - 0.5))) * step(0.5, fract(vUv.y / 60.0)); + vec3 c = vec3(0.9, 0.95, 1.0) * (edge * 0.9 + cl * 0.7) + vec3(0.05, 0.08, 0.1); + gl_FragColor = vec4(c * uAlpha, 1.0); + } + `, + }); + out.runways = new THREE.Mesh(g, m); + out.runways.frustumCulled = false; + group.add(out.runways); + out.strobes = makePoints(strobes.length, { size: 5, color: '#ffffff' }); + strobes.forEach((s, i) => out.strobes.pos.set([s[0] * KM, 0.004, -s[1] * KM], i * 3)); + out.strobeK = strobes.map((s) => s[2]); + out.strobes.geo.getAttribute('position').needsUpdate = true; + group.add(out.strobes.pts); + } + if (aw?.taxiways?.length) { + const lines = aw.taxiways.map((t) => { + const pts = []; + for (let i = 0; i < t.length - 1; i += 2) pts.push([t[i], t[i + 1], 1.2]); + return pts; + }); + out.taxi = makePolylines(lines, { color: '#ffc04a', alpha: 0.75 }); + group.add(out.taxi); + } + if (aw?.aprons?.length) { + const lines = aw.aprons.map((t) => { + const pts = []; + for (let i = 0; i < t.length - 1; i += 2) pts.push([t[i], t[i + 1], 1]); + if (pts.length) pts.push(pts[0]); + return pts; + }); + out.aprons = makePolylines(lines, { color: '#2fd0ff', alpha: 0.4 }); + group.add(out.aprons); + } + + // transit: route-coloured lines with pulses, vehicles riding them + if (d.transit?.shapes?.length) { + const lines = []; + const colors = []; + out.shapes = []; + for (const s of d.transit.shapes) { + const rid = s[0]; + const route = d.transit.routes[rid] || {}; + const pts = []; + for (let i = 1; i < s.length - 1; i += 2) pts.push([s[i], s[i + 1], route.type === 3 ? 2 : 6]); + const col = route.color ? (route.color.startsWith('#') ? route.color : '#' + route.color) : '#38e8ff'; + // subway and rail glow brighter than buses + lines.push(pts); + colors.push(route.type === 3 ? new THREE.Color(col).multiplyScalar(0.45).getStyle() : col); + out.shapes.push({ rid, pts, type: route.type }); + } + out.transitLines = makePolylines(lines, { colors }); + out.transitLines.material.uniforms.uPulse.value = 1; + group.add(out.transitLines); + out.stats.routes = Object.keys(d.transit.routes).length; + } + if (d.vehicles?.v?.length) { + const vs = d.vehicles.v; + out.veh = makePoints(vs.length, { size: 9 }); + vs.forEach((v, i) => { + const r = d.transit?.routes?.[v[4]]; + const c = new THREE.Color(r?.color ? (r.color.startsWith('#') ? r.color : '#' + r.color) : '#ffffff'); + out.veh.col.set([c.r, c.g, c.b], i * 3); + out.veh.sz[i] = r?.type === 3 ? 0.65 : 1.2; + }); + out.veh.geo.getAttribute('color').needsUpdate = true; + out.veh.geo.getAttribute('aS').needsUpdate = true; + group.add(out.veh.pts); + out.stats.vehicles = vs.length; + } + + // street cameras: beams + base rings + if (d.cameras?.length) { + const lines = d.cameras.map((c) => [[c[0], c[1], 0], [c[0], c[1], 90]]); + out.camBeams = makePolylines(lines, { color: '#eafcff', alpha: 0.5 }); + group.add(out.camBeams); + out.camPts = makePoints(d.cameras.length, { size: 7, color: '#bff6ff' }); + d.cameras.forEach((c, i) => out.camPts.pos.set([c[0] * KM, 0.09, -c[1] * KM], i * 3)); + out.camPts.geo.getAttribute('position').needsUpdate = true; + group.add(out.camPts.pts); + out.stats.cameras = d.cameras.length; + } + + // aircraft (real ADS-B): glyphs + real track history + if (d.air?.a?.length) { + const a = d.air.a; + out.airPts = makePoints(a.length + 8, { size: 8, color: '#ffffff' }); + a.forEach((p, i) => { + const heli = p[8] === 'A7' || /^(H|EC|AS|S7|S9|B06|B407|B429|A109|A119|A139|R44|R66|EC|H1|H6)/.test(p[7] || ''); + const mil = /^(RCH|CNV|PAT|EVAC|TOPCT|HKY|SPAR|SAM)/.test(p[6] || ''); + const c = new THREE.Color(heli ? '#7dffd6' : mil ? '#ffb547' : '#ffffff'); + out.airPts.col.set([c.r, c.g, c.b], i * 3); + p.heli = heli; + }); + out.airPts.geo.getAttribute('color').needsUpdate = true; + group.add(out.airPts.pts); + const tr = d.air.tracks || {}; + const tl = Object.values(tr).filter((t) => t.length > 1).map((t) => t.map((q) => [q[0], q[1], q[2] * 1.5])); + if (tl.length) { + out.airTracks = makePolylines(tl.map((t) => t.filter((q) => Math.abs(q[0]) < half * 1.2 && Math.abs(q[1]) < half * 1.2)).filter((t) => t.length > 1), { color: '#9fe8ff', alpha: 0.3 }); + group.add(out.airTracks); + } + out.stats.aircraft = a.length; + out.stats.helis = a.filter((p) => p.heli).length; + } + + // modelled extras (labelled MODELED): helicopter tours, circuits + out.modeled = []; + if (opts.modeled) for (const m of opts.modeled) out.modeled.push(m); + if (out.modeled.length) { + out.modPts = makePoints(out.modeled.length, { size: 9, color: '#7dffd6' }); + out.modeled.forEach((m, i) => { + const c = new THREE.Color(m.color || '#7dffd6'); + out.modPts.col.set([c.r, c.g, c.b], i * 3); + }); + out.modPts.geo.getAttribute('color').needsUpdate = true; + group.add(out.modPts.pts); + out.modTrails = makePolylines(out.modeled.map((m) => m.path.concat([m.path[0]])), { color: '#7dffd6', alpha: 0.25 }); + out.modTrails.material.vertexColors = true; + group.add(out.modTrails); + } + + // ---------------------------------------------------------------- per-frame + out.update = (lt, k, pxScale = 1) => { + // lt: seconds into this scene; k: 0..1 progress + const reveal = Math.min(1.6, 0.22 + lt * 1.05); + ground.material.uniforms.uReveal.value = reveal * 1.3; + ground.material.uniforms.uT.value = lt; + if (d.elev) ground.material.uniforms.uScan.value = -half * KM + ((lt * 0.35) % 1.2) * 2.4 * half * KM; + if (out.buildings) { + out.buildings.mesh.material.uniforms.uReveal.value = reveal * half * KM * 0.9; + out.buildings.mesh.material.uniforms.uT.value = lt; + } + if (out.roads) { + out.roads.mesh.material.uniforms.uReveal.value = reveal * 1.1; + out.roads.mesh.material.uniforms.uT.value = lt; + } + if (out.convoy) { + out.convoy.mesh.material.uniforms.uReveal.value = reveal * 1.1; + out.convoy.mesh.material.uniforms.uT.value = lt; + } + if (out.curtain) { + out.curtain.material.uniforms.uT.value = lt; + out.curtain.material.uniforms.uAlpha.value = clamp((lt - 0.6) * 1.5); + } + if (out.runways) out.runways.material.uniforms.uAlpha.value = clamp(lt * 1.2); + if (out.strobes) { + // the "rabbit": a pulse running in toward each threshold, twice a second + const ph = (lt * 2) % 1; + out.strobeK.forEach((kk, i) => (out.strobes.sz[i] = Math.abs((1 - kk / 30) - ph) < 0.05 ? 2.2 : 0.35)); + out.strobes.geo.getAttribute('aS').needsUpdate = true; + out.strobes.pts.material.uniforms.uPx.value = pxScale; + } + if (out.transitLines) { + out.transitLines.material.uniforms.uT.value = lt; + out.transitLines.material.uniforms.uDraw.value = lt * 9000; + } + if (out.veh) { + const vs = d.vehicles.v; + for (let i = 0; i < vs.length; i++) { + const v = vs[i]; + // dead-reckon along the reported bearing at the reported speed, sped up + const sp = Math.max(4, v[3] || 8) * 6; + const b = ((v[2] || 0) * Math.PI) / 180; + const x = v[0] + Math.sin(b) * sp * lt; + const y = v[1] + Math.cos(b) * sp * lt; + out.veh.pos.set([x * KM, 0.012, -y * KM], i * 3); + } + out.veh.geo.getAttribute('position').needsUpdate = true; + out.veh.pts.material.uniforms.uAlpha.value = clamp((lt - 0.8) * 2); + out.veh.pts.material.uniforms.uPx.value = pxScale; + } + if (out.camPts) { + out.camPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.9) * 2); + out.camBeams.material.uniforms.uAlpha.value = clamp((lt - 0.9) * 2) * 0.45; + out.camPts.pts.material.uniforms.uPx.value = pxScale; + } + if (out.airPts) { + const a = d.air.a; + for (let i = 0; i < a.length; i++) { + const p = a[i]; + const b = ((p[3] || 0) * Math.PI) / 180; + const sp = (p[4] || 0) * 3; + const x = p[0] + Math.sin(b) * sp * lt; + const y = p[1] + Math.cos(b) * sp * lt; + const z = Math.max(0, (p[2] || 0) + (p[5] || 0) * 3 * lt) * 1.5; + out.airPts.pos.set([x * KM, z * KM + 0.004, -y * KM], i * 3); + p.now = [x, y, z]; + } + out.airPts.geo.getAttribute('position').needsUpdate = true; + out.airPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.5) * 2); + out.airPts.pts.material.uniforms.uPx.value = pxScale; + if (out.airTracks) out.airTracks.material.uniforms.uAlpha.value = clamp((lt - 0.5) * 2) * 0.4; + } + if (out.modPts) { + out.modeled.forEach((m, i) => { + const p = pathAt(m.path, (lt * m.speed + m.phase) % 1); + out.modPts.pos.set([p[0] * KM, p[2] * KM, -p[1] * KM], i * 3); + m.now = p; + }); + out.modPts.geo.getAttribute('position').needsUpdate = true; + out.modPts.pts.material.uniforms.uAlpha.value = clamp((lt - 0.7) * 2); + out.modPts.pts.material.uniforms.uPx.value = pxScale; + out.modTrails.material.uniforms.uAlpha.value = clamp((lt - 0.7) * 2) * 0.22; + } + if (out.taxi) out.taxi.material.uniforms.uAlpha.value = clamp((lt - 0.4) * 2) * 0.7; + if (out.aprons) out.aprons.material.uniforms.uAlpha.value = clamp((lt - 0.4) * 2) * 0.35; + }; + return out; +} + +function pathAt(path, f) { + const n = path.length; + const x = f * n; + const i = Math.floor(x) % n; + const j = (i + 1) % n; + const u = x - Math.floor(x); + const a = path[i]; + const b = path[j]; + return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u, (a[2] ?? 0) + ((b[2] ?? 0) - (a[2] ?? 0)) * u]; +} + +function pip(x, y, pts) { + let c = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [xi, yi] = pts[i]; + const [xj, yj] = pts[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; + } + return c; +} + +// An ellipse/loop path in metres, for modelled helicopter tours and circuits. +export function loopPath(cx, cy, rx, ry, alt, rot = 0, n = 48) { + const out = []; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + const x = Math.cos(a) * rx; + const y = Math.sin(a) * ry; + out.push([cx + x * Math.cos(rot) - y * Math.sin(rot), cy + x * Math.sin(rot) + y * Math.cos(rot), alt]); + } + return out; +} diff --git a/intro-sequence/src/hud.js b/intro-sequence/src/hud.js index cf01e161b..f42a000e2 100644 --- a/intro-sequence/src/hud.js +++ b/intro-sequence/src/hud.js @@ -1,4 +1,4 @@ -import { T, FEEDS, CAPTURE_SLOW, clamp, lerp, ramp, smooth, win, easeInOut, easeOut, easeIn, expoOut, count, hash1, mulberry32 } from './timeline.js'; +import { T, FEEDS, CAPTURE_SLOW, DURATION, clamp, lerp, ramp, smooth, win, easeInOut, easeOut, easeIn, expoOut, count, hash1, mulberry32 } from './timeline.js'; import { angDist, fmtLat, fmtLon, llToVec, EARTH_KM, D2R } from './geo.js'; // Everything typographic and 2D: boot console, the dish, captures, the feed @@ -26,7 +26,7 @@ const fmt = (n) => Math.round(n).toLocaleString('en-US'); const pad = (n, k) => String(n).padStart(k, '0'); export function createHUD(opts) { - const { W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL } = opts; + const { W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL, flurry = [], nycCams = [] } = opts; const u = Math.min(W, H) / 1080; const M = Math.round((PORTRAIT ? 52 : 58) * u); const canvas = document.createElement('canvas'); @@ -77,7 +77,7 @@ export function createHUD(opts) { } // graded CCTV stills: slight desaturation, crushed blacks, scanlines baked in - const camTiles = cams.map((c) => { + const camTiles = cams.concat(nycCams).map((c) => { const cw = 320; const ch = 180; const cv = document.createElement('canvas'); @@ -94,6 +94,15 @@ export function createHUD(opts) { return { ...c, tile: cv }; }); + // detection totals across every still we hold (YOLOv8n, run once offline) + const detTotals = { all: 0, person: 0, vehicle: 0 }; + for (const c of cams.concat(nycCams)) + for (const b of c.det || []) { + detTotals.all++; + if (b[5] === 'person') detTotals.person++; + if (['car', 'truck', 'bus', 'motorcycle'].includes(b[5])) detTotals.vehicle++; + } + // ---------------------------------------------------------- primitives function text(s, x, y, { font = mono(14), color = WH, align = 'left', alpha = 1, spacing = 0, baseline = 'alphabetic' } = {}) { if (alpha <= 0.002 || !s) return 0; @@ -593,13 +602,13 @@ export function createHUD(opts) { function drawIndexing(S) { const t = S.t; if (t < T.indexStart || t > T.indexed + 1.6) return; - const a = win(t, T.indexStart, T.capFull + 0.3, 0.3, 0.3); + const a = win(t, T.indexStart, T.indexed, 0.3, 0.3); const x = M + 6 * u; const y = PORTRAIT ? H * 0.1 + 40 * u : H * 0.2; if (a > 0) { text(scramble('INDEXING SURFACE', t, T.indexStart, 0.6), x, y, { font: sans(30, 300), color: WH, alpha: a, spacing: 9 * u }); text(scramble('ESRI WORLD IMAGERY · WEB MERCATOR QUADTREE', t, T.indexStart + 0.3, 0.7, 2), x, y + 28 * u, { font: mono(12), color: DIM, alpha: a, spacing: 1.5 * u }); - const zk = ramp(t, T.indexStart + 0.4, T.capFull); + const zk = ramp(t, T.indexStart + 0.4, T.flurryEnd); const z = Math.round(lerp(3, 19, Math.pow(zk, 0.8))); const tiles = (Math.pow(4, z + 1) - 1) / 3; const gsd = 156543.03 / 2 ** z; @@ -645,11 +654,11 @@ export function createHUD(opts) { function drawCaptures(S) { const t = S.t; if (!heroes.length || t < T.capStart - 0.4) return; - const sheetA = smooth(t, T.capStart - 0.4, T.capStart + 0.2) * (1 - smooth(t, T.capFull + 2.6, T.capFull + 3.0)); + const sheetA = smooth(t, T.capStart - 0.4, T.capStart + 0.2) * (1 - smooth(t, T.flurryEnd - 0.2, T.indexed)); // empty slots if (sheetA > 0) { sheet.slots.forEach((s, i) => { - const filled = t >= capT[i] + (i < CAPTURE_SLOW ? 1.75 : 0); + const filled = t >= capT[i] + (i < CAPTURE_SLOW ? 1.5 : 0); ctx.save(); ctx.globalAlpha = sheetA * (filled ? 0 : 0.45); ctx.strokeStyle = FAINT; @@ -657,7 +666,7 @@ export function createHUD(opts) { ctx.strokeRect(s.x + 0.5, s.y + 0.5, s.w - 1, s.h - 1); ctx.restore(); }); - const done = capT.filter((c, i) => t >= c + (i < CAPTURE_SLOW ? 1.75 : 0.0)).length; + const done = capT.filter((c, i) => t >= c + (i < CAPTURE_SLOW ? 1.5 : 0.0)).length; const ly = sheet.y0 - 14 * u; text('SYNTHETIC CAPTURES', sheet.x0, ly, { font: mono(12, 500), color: CY, alpha: sheetA, spacing: 2 * u }); text(`${pad(done, 2)}/${pad(heroes.length, 2)}`, sheet.x0 + sheet.sw, ly, { font: mono(12), color: WH, alpha: sheetA, align: 'right', spacing: 2 * u }); @@ -669,7 +678,7 @@ export function createHUD(opts) { const s = sheet.slots[i]; const t0 = capT[i]; const slow = i < CAPTURE_SLOW; - const landT = slow ? t0 + 1.75 : t0; + const landT = slow ? t0 + 1.5 : t0; if (t < landT) return; if (i === fullIdx && t >= T.capFull && t < T.capFull + 3.0) return; const k = t - landT; @@ -699,7 +708,7 @@ export function createHUD(opts) { for (let i = 0; i < Math.min(CAPTURE_SLOW, heroes.length); i++) { const t0 = capT[i]; const k = t - t0; - if (k < 0 || k > 1.75) continue; + if (k < 0 || k > 1.5) continue; const h = heroes[i]; const lab = capLabel(h, i); const p = S.project(h.lon, h.lat, 1.0); @@ -710,7 +719,7 @@ export function createHUD(opts) { } // card rect: appear → hold → fly to slot const s = sheet.slots[i]; - const fly = easeInOut(ramp(k, 1.4, 1.75)); + const fly = easeInOut(ramp(k, 1.2, 1.5)); const open = easeOut(ramp(k, 0.05, 0.3)); const R = { x: lerp(bigCard.x, s.x, fly), @@ -872,7 +881,8 @@ export function createHUD(opts) { const t = S.t; if (t < T.air - 0.6 || t > T.cut) return; const wallDim = 1 - win(t, T.cctvWall - 0.1, T.cctvOut + 0.3, 0.2, 0.3); - const a = smooth(t, T.air - 0.6, T.air) * (1 - smooth(t, T.saturate - 0.1, T.saturate + 0.7)) * wallDim; + const a = smooth(t, T.air - 0.6, T.air) * (1 - smooth(t, T.descend, T.descend + 0.5)) * wallDim; + if (t > T.descend + 0.6) return; // a soft dark gutter keeps the stack legible over the planet if (a > 0) { ctx.save(); @@ -1063,6 +1073,7 @@ export function createHUD(opts) { ctx.fillStyle = 'rgba(255,255,255,0.05)'; ctx.fillRect(x, y + band, w, h * 0.08); ctx.restore(); + if (c.det && w > 60 * u && h > 30 * u) detBoxes(c.det, x, y, w, h, alpha * 0.9, t, w > 150 * u); if (w > 90 * u) { const fs = Math.max(7, Math.min(10, w / 26 / u)); text((c.name || '').toUpperCase().slice(0, Math.floor(w / (fs * 0.62 * u)) - 2), x + 5 * u, y + 12 * u, { font: mono(fs, 500), color: WH, alpha: alpha * 0.9, spacing: 0.5 * u }); @@ -1091,7 +1102,7 @@ export function createHUD(opts) { const t = S.t; if (!camTiles.length || t < T.cctv || t > T.cctvOut + 0.8) return; // tethered feeds pinned to the globe - if (t < T.cctvWall + 0.3) { + if (t < T.cctvWall + 0.3 && T.cctv < T.cctvWall) { const a = 1 - smooth(t, T.cctvWall - 0.1, T.cctvWall + 0.25); const vis = []; for (const c of camTiles) { @@ -1183,7 +1194,7 @@ export function createHUD(opts) { const cy = H / 2; const shown = count(t, T.cctvWall + 0.5, T.cctvOut - 0.6, cams.length, counts.cctv, expoOut); const bw = (PORTRAIT ? 620 : 700) * u; - const bh = 150 * u; + const bh = 184 * u; ctx.save(); ctx.globalAlpha = pa * 0.88; ctx.fillStyle = '#000'; @@ -1192,7 +1203,16 @@ export function createHUD(opts) { brackets(W / 2 - bw / 2, cy - bh / 2, bw, bh, 14 * u, CY, pa, 1.5); text('OPTICAL NETWORK', W / 2, cy - 30 * u, { font: mono(13, 500), color: CY, align: 'center', alpha: pa, spacing: 6 * u }); text(fmt(shown), W / 2, cy + 26 * u, { font: sans(56, 200), color: WH, align: 'center', alpha: pa, spacing: 4 * u }); + text(`OBJECTS IN FRAME ${fmt(detTotals.all)} · PEOPLE ${fmt(detTotals.person)} · VEHICLES ${fmt(detTotals.vehicle)}`, W / 2, cy + 76 * u, { font: mono(10, 500), color: CY, align: 'center', alpha: pa, spacing: 1.5 * u }); text(`PUBLIC CAMERAS · ${counts.cctvSources} AGENCIES · ${counts.cctvCountries} COUNTRIES`, W / 2, cy + 56 * u, { font: mono(11), color: DIM, align: 'center', alpha: pa, spacing: 2 * u }); + // the heartbeat of the cities, from what the cameras see + const pyb = H - 64 * u; + ctx.save(); + ctx.globalAlpha = pa * 0.85; + ctx.fillStyle = '#000'; + ctx.fillRect(0, pyb - 26 * u, W, 64 * u); + ctx.restore(); + cityPulse(M, pyb - 6 * u, W - 2 * M, 40 * u, pa, t, camTiles, 'CITY PULSE · OBJECTS DETECTED PER CAMERA · YOLOV8N'); } } } @@ -1217,11 +1237,11 @@ export function createHUD(opts) { // status lines const lines = [ - [53.4, 'FEEDS', `${pad(FEEDS.length + rapidList.length, 2)}/${pad(FEEDS.length + rapidList.length, 2)} LIVE`], - [54.5, 'COVERAGE', '510,072,000 KM²'], - [55.6, 'TWIN', 'SYNCHRONISED'], - [56.7, 'LATENCY', `${212 - Math.floor(ramp(t, 56.7, 58) * 170)} MS`], - [57.7, 'MODEL', 'EXCEEDS SUBSTRATE'], + [77.4, 'HUMAN ACTIVITY', 'INGESTED'], + [78.5, 'NON-HUMAN ACTIVITY', 'INGESTED'], + [79.6, 'COVERAGE', '510,072,000 KM²'], + [80.7, 'LATENCY', `${212 - Math.floor(ramp(t, 80.7, 82) * 170)} MS`], + [81.7, 'TWIN', 'EXCEEDS SUBSTRATE'], ]; const font = mono(13); const lx = PORTRAIT ? W / 2 - 230 * u : W - M - 446 * u; @@ -1251,8 +1271,8 @@ export function createHUD(opts) { const oa = smooth(t, T.omni, T.omni + 0.12) * (1 - smooth(t, T.cut - 0.1, T.cut)); if (oa > 0) { const y = PORTRAIT ? H * 0.8 : H * 0.78; - text('OBSERVER STATE', W / 2, y - 46 * u, { font: mono(12, 500), color: CY, align: 'center', alpha: oa, spacing: 6 * u }); - plate(scramble('OMNIPRESENT', t, T.omni, 0.35), W / 2, y + 4 * u, { font: sans(PORTRAIT ? 34 : 38, 500), align: 'center', alpha: oa, spacing: 14, padX: 26, padY: 14 }); + text('TWIN STATE', W / 2, y - 46 * u, { font: mono(12, 500), color: CY, align: 'center', alpha: oa, spacing: 6 * u }); + plate(scramble('ALIVE', t, T.omni, 0.35), W / 2, y + 4 * u, { font: sans(PORTRAIT ? 34 : 38, 500), align: 'center', alpha: oa, spacing: 14, padX: 26, padY: 14 }); } } @@ -1261,7 +1281,7 @@ export function createHUD(opts) { const t = S.t; if (t < T.title) return; const { cx, cy, s } = eyeLayout; - const fadeEnd = 1 - smooth(t, T.fadeOut, 72); + const fadeEnd = 1 - smooth(t, T.fadeOut, DURATION); ctx.save(); ctx.translate(cx - EYE_C.x * s, cy - EYE_C.y * s); ctx.scale(s, s); @@ -1303,7 +1323,7 @@ export function createHUD(opts) { const wsize = PORTRAIT ? 64 : 70; text(scramble("GOD'S EYE VIEW", t, T.wordmark, 0.9, 4), W / 2 + 10 * u, wy, { font: sans(wsize, 200), color: WH, align: 'center', alpha: wa, spacing: (PORTRAIT ? 18 : 26) * u }); const ta = smooth(t, T.tagline, T.tagline + 0.2) * fadeEnd; - text(typed('NO PLACE LEFT BEHIND.', t, T.tagline, 22), W / 2, wy + 52 * u, { font: mono(17), color: DIM, align: 'center', alpha: ta, spacing: 7 * u }); + text(scramble('OMNISCIENCE', t, T.tagline, 0.9, 8), W / 2, wy + 58 * u, { font: mono(20, 500), color: CY, align: 'center', alpha: ta, spacing: 16 * u }); const oa = smooth(t, T.online, T.online + 0.15) * fadeEnd; if (oa > 0) { const y = wy + (PORTRAIT ? 130 : 112) * u; @@ -1321,7 +1341,7 @@ export function createHUD(opts) { text('PUBLIC SIGNALS · OPEN SOURCE · RUNS IN YOUR BROWSER', W / 2, y + 30 * u, { font: mono(10), color: FAINT, align: 'center', alpha: oa * smooth(t, T.online + 0.6, T.online + 1.2), spacing: 2.5 * u }); } // sources, as the feeds came in - const ca = smooth(t, T.online + 0.8, T.online + 1.6) * fadeEnd; + const ca = smooth(t, T.tagline + 1.6, T.tagline + 2.4) * fadeEnd; if (ca > 0) { const credits = [ 'IMAGERY ESRI WORLD IMAGERY (ESRI, MAXAR, EARTHSTAR GEOGRAPHICS) · NASA GIBS BLACK MARBLE · VIIRS', @@ -1335,6 +1355,449 @@ export function createHUD(opts) { } } + // ---------------------------------------------------------- v2: the eye opens + const SENSORS = ['VIS', 'NIR', 'SWIR', 'MWIR', 'LWIR', 'SAR-X', 'SAR-C', 'RF', 'ADS-B', 'AIS', 'SEISMIC', 'GNSS-R', 'LIDAR', 'OPTICAL·CCTV']; + function drawAwakening(S) { + const t = S.t; + if (t < T.seed || t > T.indexStart + 1.2) return; + const fade = 1 - smooth(t, T.substrate + 0.2, T.indexStart + 1.0); + const cx = S.center.x; + const cy = S.center.y; + const R = S.limbR; + // iris aperture: seven blades retract from the seed to the limb + const ak = easeInOut(ramp(t, T.aperture, T.growStart + 1.4)); + if (t < T.growStart + 2.2) { + const ri = lerp(6 * u, R * 1.02, ak); + const ro = R + 10 * u; + const a = smooth(t, T.seed + 0.3, T.aperture) * (1 - smooth(t, T.growStart + 1.2, T.growStart + 2.2)); + ctx.save(); + ctx.translate(cx, cy); + ctx.globalAlpha = a * 0.75; + ctx.strokeStyle = CY; + ctx.lineWidth = 1 * u; + const rot = ak * 1.4 + t * 0.05; + const N = 7; + for (let i = 0; i < N; i++) { + const a0 = rot + (i / N) * Math.PI * 2; + const a1 = a0 + (Math.PI * 2) / N; + ctx.beginPath(); + ctx.moveTo(Math.cos(a0) * ri, Math.sin(a0) * ri); + ctx.lineTo(Math.cos(a1) * ri, Math.sin(a1) * ri); + // blade edge sweeping out to the ring + ctx.moveTo(Math.cos(a0) * ri, Math.sin(a0) * ri); + ctx.quadraticCurveTo(Math.cos(a0 + 0.5) * (ri + ro) * 0.55, Math.sin(a0 + 0.5) * (ri + ro) * 0.55, Math.cos(a0 + 0.9) * ro, Math.sin(a0 + 0.9) * ro); + ctx.stroke(); + } + // closed blades read as a dark iris + ctx.globalAlpha = a * 0.5 * (1 - ak); + ctx.fillStyle = 'rgba(0,30,40,0.6)'; + ctx.beginPath(); + ctx.arc(0, 0, ro, 0, Math.PI * 2); + ctx.arc(0, 0, ri, 0, Math.PI * 2, true); + ctx.fill('evenodd'); + ctx.restore(); + const fnum = lerp(22, 1.4, ak); + text(`APERTURE f/${fnum < 2 ? fnum.toFixed(1) : Math.round(fnum)}`, cx, cy + R + 64 * u, { font: mono(11, 500), color: CY, align: 'center', alpha: a, spacing: 4 * u }); + } + // radar sweep across the disc while the culture grows + const sw = win(t, T.growStart, T.substrate + 0.8, 0.5, 0.8) * fade; + if (sw > 0) { + const ang = (t - T.growStart) * 2.4; + ctx.save(); + ctx.translate(cx, cy); + ctx.beginPath(); + ctx.arc(0, 0, R, 0, Math.PI * 2); + ctx.clip(); + for (let i = 0; i < 24; i++) { + const a0 = ang - i * 0.02; + ctx.globalAlpha = sw * 0.12 * (1 - i / 24); + ctx.fillStyle = CY; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, R, a0 - 0.02, a0); + ctx.fill(); + } + ctx.globalAlpha = sw * 0.7; + ctx.strokeStyle = '#bff8ff'; + ctx.lineWidth = 1.2 * u; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(ang) * R, Math.sin(ang) * R); + ctx.stroke(); + ctx.restore(); + } + // orbiting coordinate text ring + const ta = win(t, T.dishDone - 0.2, T.indexStart + 1.0, 0.6, 0.6); + if (ta > 0) { + const str = ' 00°00′00″N 000°00′00″E · OBSERVER-1 · SUBSTRATE · CULTURE 001 · SENSORS ONLINE ·'.repeat(3); + const rr = R + 52 * u; + const n = Math.floor((Math.PI * 2 * rr) / (8.2 * u)); + ctx.save(); + ctx.translate(cx, cy); + ctx.font = mono(9); + ctx.fillStyle = CY; + ctx.textAlign = 'center'; + ctx.globalAlpha = ta * 0.55; + const rot = -t * 0.06; + for (let i = 0; i < n; i++) { + const a = rot + (i / n) * Math.PI * 2; + ctx.save(); + ctx.rotate(a); + ctx.translate(0, -rr); + ctx.fillText(str[i % str.length], 0, 0); + ctx.restore(); + } + ctx.restore(); + } + // sensors waking, one band at a time + const sa = win(t, T.growStart + 0.2, T.indexStart + 1.0, 0.3, 0.6); + if (sa > 0) { + const x0 = PORTRAIT ? M + 6 * u : W - M - 250 * u; + const y0 = PORTRAIT ? H - M - 330 * u : H * 0.24; + text('SENSOR ARRAY', x0, y0, { font: mono(11, 500), color: CY, alpha: sa, spacing: 3 * u }); + line(x0, y0 + 8 * u, x0 + 240 * u, y0 + 8 * u, CY, sa * 0.5); + SENSORS.forEach((name, i) => { + const st = T.growStart + 0.3 + i * ((T.growEnd - T.growStart - 0.6) / SENSORS.length); + const k = t - st; + if (k < 0) return; + const y = y0 + (26 + i * 18) * u; + const ra = sa * Math.min(1, k * 6); + text(name, x0, y, { font: mono(10), color: WH, alpha: ra, spacing: 1.2 * u }); + const lvl = clamp(k / 0.8) * (0.55 + 0.45 * hash1(i * 7.3)); + ctx.save(); + ctx.globalAlpha = ra; + ctx.fillStyle = 'rgba(0,246,255,0.18)'; + ctx.fillRect(x0 + 96 * u, y - 7 * u, 90 * u, 5 * u); + ctx.fillStyle = CY; + ctx.fillRect(x0 + 96 * u, y - 7 * u, 90 * u * lvl, 5 * u); + ctx.restore(); + text(k < 0.8 ? 'SYNC' : 'LIVE', x0 + 240 * u, y, { font: mono(9, 500), color: k < 0.8 ? AMB : CY, alpha: ra, align: 'right', spacing: 1 * u }); + }); + } + // optics calibration, converging + const ca = win(t, T.seed + 0.6, T.indexStart + 1.0, 0.4, 0.6); + if (ca > 0 && !PORTRAIT) { + const x0 = M + 6 * u; + const y0 = H * 0.24; + text('CALIBRATING OPTICS', x0, y0, { font: mono(11, 500), color: CY, alpha: ca, spacing: 3 * u }); + line(x0, y0 + 8 * u, x0 + 240 * u, y0 + 8 * u, CY, ca * 0.5); + const focus = 1 - Math.exp(-(t - T.seed) * 0.45); + const rows = [['FOCUS', focus.toFixed(3)], ['EXPOSURE', `${(-6 + focus * 6.3).toFixed(2)} EV`], ['NOISE FLOOR', `${(-12 - focus * 30).toFixed(1)} dB`], ['DYNAMIC RANGE', `${(4 + focus * 16).toFixed(1)} STOPS`]]; + rows.forEach(([k, v], i) => { + const y = y0 + (28 + i * 20) * u; + text(k, x0, y, { font: mono(10), color: DIM, alpha: ca, spacing: 1.2 * u }); + text(v, x0 + 240 * u, y, { font: mono(10, 500), color: WH, alpha: ca, align: 'right', spacing: 1.2 * u }); + }); + // histogram settling + const hy = y0 + 130 * u; + ctx.save(); + ctx.globalAlpha = ca * 0.85; + for (let i = 0; i < 48; i++) { + const xx = i / 47; + const target = Math.exp(-Math.pow((xx - 0.32) / 0.14, 2)) + 0.5 * Math.exp(-Math.pow((xx - 0.8) / 0.06, 2)); + const noise = hash1(i * 3.1 + Math.floor(t * 12)) * (1 - focus); + const v = target * focus + noise * 0.8; + ctx.fillStyle = i % 8 === 0 ? WH : CY; + ctx.fillRect(x0 + i * 5 * u, hy - v * 46 * u, 3.2 * u, v * 46 * u); + } + ctx.restore(); + line(x0, hy + 2 * u, x0 + 240 * u, hy + 2 * u, CY, ca * 0.4); + } + } + + // ---------------------------------------------------------- v2: the flurry + function drawFlurry(S) { + const t = S.t; + if (!flurry.length || t < T.flurry - 0.2 || t > T.indexed + 0.4) return; + const fa = 1 - smooth(t, T.flurryEnd, T.indexed + 0.2); + let shown = 0; + const sz0 = (PORTRAIT ? 92 : 78) * u; + for (const f of flurry) { + if (f.t === undefined) continue; + const k = t - f.t; + if (k >= 0) shown++; + const life = 0.42; + if (k < 0 || k > life) continue; + const p = S.project(f.lon, f.lat, 1.0); + if (!p.vis || p.facing < 0.12) continue; + const pop = easeOut(Math.min(1, k / 0.1)); + const a = fa * (1 - smooth(k, life * 0.6, life)); + const sz = sz0 * (0.55 + 0.45 * pop) * (0.6 + 0.4 * p.facing); + const x = p.x - sz / 2; + const y = p.y - sz - 10 * u; + ctx.save(); + ctx.globalAlpha = a; + ctx.drawImage(f.img, x, y, sz, sz); + const fl = Math.exp(-k * 18); + if (fl > 0.02) { + ctx.fillStyle = `rgba(230,255,255,${fl * 0.9})`; + ctx.fillRect(x, y, sz, sz); + } + ctx.restore(); + brackets(x - 2 * u, y - 2 * u, sz + 4 * u, sz + 4 * u, 6 * u, CY, a * 0.9, 1); + line(p.x, p.y, p.x, y + sz, CY, a * 0.5, 0.8); + ctx.save(); + ctx.globalAlpha = a; + ctx.fillStyle = WH; + ctx.beginPath(); + ctx.arc(p.x, p.y, 2 * u, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + if (sz > 56 * u && hash1(f.lat * 3.7) > 0.55) text((f.name || '').toUpperCase().slice(0, 18), x, y - 5 * u, { font: mono(8, 500), color: WH, alpha: a * 0.85, spacing: 0.8 * u }); + } + // sweep readout + const ra = win(t, T.flurry, T.indexed + 0.3, 0.2, 0.3); + if (ra > 0) { + const x = M + 6 * u; + const y = (PORTRAIT ? H * 0.1 + 40 * u : H * 0.2) + 160 * u; + const rate = Math.round(lerp(6, 64, ramp(t, T.flurry, T.flurryEnd - 1))); + text('GLOBAL SWEEP', x, y, { font: mono(12, 500), color: CY, alpha: ra, spacing: 3 * u }); + text(`CAPTURES ${fmt(heroes.length + shown)}`, x, y + 22 * u, { font: mono(13, 500), color: WH, alpha: ra, spacing: 1.5 * u }); + text(`RATE ${rate} / S`, x, y + 42 * u, { font: mono(13), color: WH, alpha: ra, spacing: 1.5 * u }); + } + } + + // ---------------------------------------------------------- v2: cities + const CLS_COL = { person: '#ffd24a', car: CY, truck: '#ff8a3d', bus: '#ff8a3d', bicycle: '#7dffd6', motorcycle: '#7dffd6', 'traffic light': '#bfa8ff', 'stop sign': '#ff5b5b' }; + function detBoxes(det, x, y, w, h, alpha, t, labels = true) { + if (!det || !det.length || alpha <= 0) return 0; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.lineWidth = Math.max(1, 1.1 * u); + let n = 0; + for (const b of det) { + const [x0, y0, x1, y1, conf, cls] = b; + const col = CLS_COL[cls] || WH; + const j = Math.sin(t * 9 + x0 * 40) * 0.6 * u; + const bx = x + x0 * w + j; + const by = y + y0 * h; + const bw = (x1 - x0) * w; + const bh = (y1 - y0) * h; + if (bw < 2 || bh < 2) continue; + ctx.strokeStyle = col; + const c = Math.min(bw, bh) * 0.3; + ctx.beginPath(); + ctx.moveTo(bx, by + c); ctx.lineTo(bx, by); ctx.lineTo(bx + c, by); + ctx.moveTo(bx + bw - c, by); ctx.lineTo(bx + bw, by); ctx.lineTo(bx + bw, by + c); + ctx.moveTo(bx + bw, by + bh - c); ctx.lineTo(bx + bw, by + bh); ctx.lineTo(bx + bw - c, by + bh); + ctx.moveTo(bx + c, by + bh); ctx.lineTo(bx, by + bh); ctx.lineTo(bx, by + bh - c); + ctx.stroke(); + ctx.globalAlpha = alpha * 0.12; + ctx.fillStyle = col; + ctx.fillRect(bx, by, bw, bh); + ctx.globalAlpha = alpha; + if (labels && bw > 18 * u) { + ctx.font = mono(7, 500); + ctx.fillStyle = col; + ctx.fillText(`${cls.toUpperCase()} ${Math.round(conf * 100)}`, bx, by - 2 * u); + } + n++; + } + ctx.restore(); + return n; + } + + function drawCity(S) { + const C = S.city; + if (!C) return; + const t = S.t; + const lt = C.lt; + const dur = C.t1 - C.t0; + const a = smooth(lt, 0.15, 0.6) * (1 - smooth(lt, dur - 0.25, dur)); + const B = C.built; + const st = B.stats || {}; + // dark gutter behind the readouts + ctx.save(); + ctx.globalAlpha = a * 0.8; + const gg = PORTRAIT ? ctx.createLinearGradient(0, 0, 0, H * 0.42) : ctx.createLinearGradient(0, 0, W * 0.36, 0); + gg.addColorStop(0, 'rgba(0,3,6,0.85)'); + gg.addColorStop(0.6, 'rgba(0,3,6,0.45)'); + gg.addColorStop(1, 'rgba(0,3,6,0)'); + ctx.fillStyle = gg; + if (PORTRAIT) ctx.fillRect(0, 0, W, H * 0.42); + else ctx.fillRect(0, 0, W * 0.36, H); + ctx.restore(); + // acquisition plate on the cut + const pa = win(lt, 0.0, 0.9, 0.05, 0.3); + if (pa > 0) plate(`ACQUIRING ▸ ${C.name}`, W / 2, H * 0.5, { font: mono(15, 500), align: 'center', alpha: pa, spacing: 3, padX: 16, padY: 10 }); + // title block + const x = M + 6 * u; + const y = PORTRAIT ? M + 110 * u : H * 0.2; + text(scramble(C.name, t, C.t0 + 0.2, 0.5, 3), x, y, { font: sans(PORTRAIT ? 40 : 34, 300), color: WH, alpha: a, spacing: 10 * u }); + text(C.sub, x, y + 28 * u, { font: mono(12), color: CY, alpha: a, spacing: 2 * u }); + text(`${fmtLat(C.lat, 4)} ${fmtLon(C.lon, 4)}`, x, y + 48 * u, { font: mono(11), color: DIM, alpha: a, spacing: 1.5 * u }); + const rows = []; + if (st.buildings) rows.push(['BUILDINGS', fmt(st.buildings), 'OSM']); + if (st.roads) rows.push(['ROAD SEGMENTS', fmt(st.roads), 'OSM']); + if (st.roads) rows.push(['TRAFFIC', 'MODELED', 'OSM GRAPH']); + if (st.cameras) rows.push(['STREET CAMERAS', fmt(st.cameras), 'NYC DOT']); + if (st.aircraft) rows.push(['AIRCRAFT', fmt(st.aircraft), 'ADS-B']); + if (st.helis) rows.push(['ROTORCRAFT', fmt(st.helis), 'ADS-B']); + if (B.modeled?.length && C.slug === 'nyc') rows.push(['HELI CORRIDORS', fmt(B.modeled.length), 'MODELED']); + if (st.routes) rows.push(['TRANSIT ROUTES', fmt(st.routes), 'MBTA GTFS']); + if (st.vehicles) rows.push(['TRANSIT VEHICLES', fmt(st.vehicles), 'GTFS-RT']); + if (B.runways) rows.push(['RUNWAYS', fmt(B.d.aeroway?.runways?.length || B.d.military?.runways?.length || 0), 'OSM']); + if (B.milPolys) rows.push(['RESTRICTED AREAS', fmt(B.milPolys.length), 'OSM']); + if (C.slug === 'bragg') rows.push(['AIRFIELD PATTERN', '3', 'MODELED']); + if (B.d.peaks?.length) rows.push(['PEAKS', fmt(B.d.peaks.length), 'OSM']); + if (B.d.elev) rows.push(['ELEVATION', `${fmt(B.d.elevMeta?.max || 8848)} M`, 'AWS TERRAIN']); + rows.forEach(([k, v, src], i) => { + const ry = y + (78 + i * 19) * u; + const ra = a * smooth(lt, 0.35 + i * 0.12, 0.6 + i * 0.12); + text(k, x, ry, { font: mono(10), color: DIM, alpha: ra, spacing: 1.2 * u }); + text(v, x + 170 * u, ry, { font: mono(11, 500), color: v === 'MODELED' ? AMB : WH, alpha: ra, spacing: 1.2 * u }); + text(src, x + 262 * u, ry, { font: mono(8), color: FAINT, alpha: ra, spacing: 1 * u }); + }); + // congestion legend + if (st.roads && C.slug !== 'everest') { + const lx = PORTRAIT ? M + 6 * u : M + 6 * u; + const ly = H - M - 70 * u; + const lw = 200 * u; + ctx.save(); + ctx.globalAlpha = a; + const g = ctx.createLinearGradient(lx, 0, lx + lw, 0); + g.addColorStop(0, '#1aff73'); + g.addColorStop(0.5, '#ffcc1a'); + g.addColorStop(1, '#ff2a1f'); + ctx.fillStyle = g; + ctx.fillRect(lx, ly, lw, 4 * u); + ctx.restore(); + text('FREE FLOW', lx, ly - 7 * u, { font: mono(9), color: DIM, alpha: a, spacing: 1 * u }); + text('CONGESTED', lx + lw, ly - 7 * u, { font: mono(9), color: DIM, alpha: a, align: 'right', spacing: 1 * u }); + text('TRAFFIC · MODELED ON OSM ROAD GRAPH', lx, ly + 18 * u, { font: mono(8), color: FAINT, alpha: a, spacing: 1 * u }); + } + // world-space labels + const la = a * smooth(lt, 0.9, 1.4); + if (la > 0) { + if (B.d.peaks) { + let n = 0; + for (const pk of B.d.peaks.slice().sort((p, q) => q[2] - p[2])) { + if (n >= 6) break; + const z = B.elevAt ? B.elevAt(pk[0], pk[1]) : pk[2]; + const p = C.project(pk[0], pk[1], z + 60); + if (!p.vis || p.x < M || p.x > W - M || p.y < M || p.y > H - M) continue; + drawCallout(p.x, p.y, (pk[3] || '').toUpperCase(), `${fmt(pk[2])} M`, n % 2 ? -1 : 1, la); + n++; + } + } + if (B.d.air?.a) { + let n = 0; + for (const p of B.d.air.a.slice().sort((p, q) => Math.hypot(p[0], p[1]) - Math.hypot(q[0], q[1]))) { + if (n >= 4 || !p.now) break; + const s = C.project(p.now[0], p.now[1], p.now[2]); + if (!s.vis || s.x < M || s.x > W - M || s.y < M * 2 || s.y > H - M * 2) continue; + const alt = Math.round((p[2] || 0) * 3.2808); + drawCallout(s.x, s.y, `${(p[6] || 'UNKNOWN').trim()}${p[7] ? ' · ' + p[7] : ''}`, `${alt ? fmt(alt) + ' FT' : 'GROUND'} · ${Math.round((p[4] || 0) * 1.944)} KT`, n % 2 ? -1 : 1, la, p.heli ? '#7dffd6' : WH); + n++; + } + } + if (B.modeled?.length) { + B.modeled.slice(0, 2).forEach((m, i) => { + if (!m.now) return; + const s = C.project(m.now[0], m.now[1], m.now[2]); + if (!s.vis) return; + drawCallout(s.x, s.y, m.name, `MODELED · ${fmt(m.now[2] * 3.28)} FT`, i % 2 ? 1 : -1, la * 0.9, m.color || '#7dffd6'); + }); + } + if (B.milPolys) { + const big = B.milPolys.slice().sort((p, q) => q.pts.length - p.pts.length)[0]; + if (big) { + let cx = 0, cy = 0; + big.pts.forEach((q) => { cx += q[0]; cy += q[1]; }); + const s = C.project(cx / big.pts.length, cy / big.pts.length, 400); + if (s.vis) plate(`RESTRICTED · ${(big.name || 'MILITARY').toUpperCase().slice(0, 28)}`, s.x, s.y, { font: mono(10, 500), align: 'center', alpha: la * 0.9, spacing: 2, inverse: false, padX: 8, padY: 5 }); + } + } + if (B.labels?.length && C.slug === 'lax') { + B.labels.filter((l) => l.kind === 'runway').slice(0, 4).forEach((l) => { + const s = C.project(l.x, l.y, 5); + if (s.vis) text(l.text, s.x, s.y - 6 * u, { font: mono(9, 500), color: WH, align: 'center', alpha: la * 0.8, spacing: 1.5 * u }); + }); + } + } + // NYC: street cameras opening onto real frames with detections + if (C.slug === 'nyc' && nycCams.length) { + const pw = (PORTRAIT ? 230 : 210) * u; + const ph = pw * 9 / 16; + const slots = PORTRAIT + ? [[W - M - pw, H * 0.22], [W - M - pw, H * 0.22 + ph + 14 * u], [W - M - pw, H * 0.22 + 2 * (ph + 14 * u)]] + : [[W - M - pw, H * 0.18], [W - M - pw, H * 0.18 + ph + 16 * u], [W - M - pw, H * 0.18 + 2 * (ph + 16 * u)], [W - M - pw, H * 0.18 + 3 * (ph + 16 * u)]]; + let total = 0; + slots.forEach(([sx, sy], i) => { + const c = nycCams[(i + Math.floor(lt / 2.2) * slots.length) % nycCams.length]; + const st0 = 2.2 + i * 0.35 + Math.floor(lt / 2.2) * 0; + const k = lt - st0; + if (k < 0) return; + const { open, glow } = tvOn(k); + const ca = a * Math.min(1, k * 5); + const hh = ph * open; + const yy = sy + (ph - hh) / 2; + ctx.save(); + ctx.globalAlpha = ca; + ctx.beginPath(); + ctx.rect(sx, yy, pw, Math.max(1, hh)); + ctx.clip(); + ctx.filter = 'saturate(0.6) contrast(1.15) brightness(0.9)'; + drawCover(c.img, sx, yy, pw, Math.max(1, hh)); + ctx.filter = 'none'; + ctx.restore(); + if (open > 0.95) total += detBoxes(c.det, sx, sy, pw, ph, ca * smooth(k, 0.35, 0.6), t); + if (glow > 0.02) { + ctx.save(); + ctx.globalAlpha = ca * glow; + ctx.fillStyle = '#e8ffff'; + ctx.fillRect(sx, yy, pw, Math.max(2 * u, hh)); + ctx.restore(); + } + brackets(sx - 3 * u, sy - 3 * u, pw + 6 * u, ph + 6 * u, 8 * u, CY, ca * 0.8, 1); + text((c.name || '').toUpperCase().slice(0, 30), sx + 5 * u, sy + 12 * u, { font: mono(8, 500), color: WH, alpha: ca, spacing: 0.5 * u }); + text(`DOT ${c.id || ''} · ${new Date(snapT.getTime() + t * 1000).toISOString().slice(11, 19)}`, sx + 5 * u, sy + ph - 6 * u, { font: mono(7), color: WH, alpha: ca * 0.8 }); + // tether to the camera's beam on the street + if (c.x !== undefined) { + const p = C.project(c.x, c.y, 90); + if (p.vis) line(p.x, p.y, sx, sy + ph / 2, CY, ca * 0.35, 0.8); + } + }); + // street pulse: detections per frame across the cameras + const pa2 = a * smooth(lt, 2.8, 3.4); + if (pa2 > 0) { + const gx = PORTRAIT ? M + 6 * u : W - M - pw; + const gy = PORTRAIT ? H - M - 150 * u : H * 0.18 + 4 * (ph + 16 * u) + 16 * u; + const gw = PORTRAIT ? W - 2 * M - 12 * u : pw; + cityPulse(gx, gy, gw, 34 * u, pa2, t, nycCams, 'STREET PULSE · OBJECTS / FRAME'); + } + } + } + + // an ECG-style trace built from real per-camera detection counts + function cityPulse(x, y, w, h, alpha, t, pool, label) { + const vals = pool.map((c) => (c.det ? c.det.length : 0)); + if (!vals.length) return; + const N = 90; + const scroll = t * 14; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = CY; + ctx.lineWidth = 1.3 * u; + ctx.beginPath(); + for (let i = 0; i <= N; i++) { + const s = i + scroll; + const idx = Math.floor(s / 6) % vals.length; + const v = vals[idx] / Math.max(1, Math.max(...vals)); + const ph = (s % 6) / 6; + // a heartbeat-shaped spike per camera, height = its object count + const spike = ph < 0.12 ? -0.25 * Math.sin((ph / 0.12) * Math.PI) : ph < 0.28 ? Math.sin(((ph - 0.12) / 0.16) * Math.PI) * v : ph < 0.4 ? -0.2 * Math.sin(((ph - 0.28) / 0.12) * Math.PI) * v : 0; + const px = x + (i / N) * w; + const py = y + h * 0.65 - spike * h * 0.8; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.stroke(); + ctx.restore(); + line(x, y + h * 0.65, x + w, y + h * 0.65, CY, alpha * 0.2, 0.8); + text(label, x, y - 4 * u, { font: mono(8, 500), color: CY, alpha, spacing: 1.2 * u }); + } + // ---------------------------------------------------------- frame function draw(S) { ctx = S.ctx; @@ -1343,9 +1806,12 @@ export function createHUD(opts) { ctx.globalAlpha = 1; drawBoot(S); drawSeed(S); + drawAwakening(S); drawDish(S); drawGrowth(S); drawIndexing(S); + drawFlurry(S); + drawCity(S); drawFeeds(S); drawCCTV(S); drawSaturation(S); diff --git a/intro-sequence/src/main.js b/intro-sequence/src/main.js index 26e1a957f..d97a1ecd3 100644 --- a/intro-sequence/src/main.js +++ b/intro-sequence/src/main.js @@ -1,6 +1,7 @@ import * as THREE from 'three'; import * as satellite from 'satellite.js'; -import { T, FPS, FEEDS, captureTimes, clamp, lerp, ramp, smooth, win, easeInOut, expoOut, mulberry32 } from './timeline.js'; +import { T, FPS, FEEDS, CITIES, DURATION, captureTimes, clamp, lerp, ramp, smooth, win, easeInOut, expoOut, mulberry32 } from './timeline.js'; +import { loadCity, buildCity, loopPath } from './city.js'; import { llToVec, subsolar, angDist, D2R, R2D, EARTH_KM } from './geo.js'; import { createGlobe } from './globe.js'; import { createCoastline, createBorders, createMycelium, createCables, createArcs, createLattice } from './lines.js'; @@ -282,7 +283,60 @@ async function init() { }; const capT = captureTimes(heroes.length); - const hud = createHUD({ W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL }); + // v2 assets: the capture flurry, street cameras, detections + const flurryIdx = (await json('imagery/flurry/index.json')) || []; + const flurry = []; + for (const f of flurryIdx) { + const im = await img(`imagery/flurry/${f.file}`); + if (im) flurry.push({ ...f, img: im }); + } + const detections = (await json('cctv/detections.json')) || {}; + const nycIdx = (await json('cctv/nyc/index.json')) || []; + const nycCams = []; + for (const c of nycIdx) { + const im = await img(`cctv/nyc/${c.file}`); + if (im) nycCams.push({ ...c, img: im, det: detections[`nyc/${c.file}`] || detections[`cctv/nyc/${c.file}`] || [] }); + } + for (const c of cams) c.det = detections[c.file] || detections[`cctv/${c.file}`] || []; + status(`v2 assets: ${flurry.length} flurry, ${nycCams.length} nyc cams, ${Object.keys(detections).length} detection sets`); + + // ---------------------------------------------------------------- cities + const cityCam = new THREE.PerspectiveCamera(PORTRAIT ? 60 : 36, W / H, 0.05, 600); + const cityScenes = []; + for (const c of CITIES) { + const d = await loadCity(c.slug); + if (!d.meta) { + console.warn('city missing', c.slug); + continue; + } + const built = buildCity(d, { modeled: modeledFor(c.slug, d) }); + cityScenes.push({ ...c, d, built }); + status(`city ${c.slug}: ${JSON.stringify(built.stats)}`); + } + window.__cities = cityScenes; + const CITY_CAMS = { + everest: [[[17, 26, 22], [0, 4.5, 0]], [[7, 16, 17], [0, 6, -1]], [[-9, 12.5, 13], [0, 6.5, 0]]], + nyc: [[[0.2, 7.5, 2.2], [0, 0, -0.3]], [[1.7, 2.7, 2.5], [0, 0.1, -0.4]], [[2.7, 1.15, 0.5], [0.1, 0.15, -0.6]]], + boston: [[[-0.4, 5.6, 3.4], [0, 0, 0]], [[1.3, 2.5, 2.7], [0, 0, -0.2]], [[2.5, 1.35, 1.0], [0.1, 0.05, -0.3]]], + lax: [[[-4.2, 2.8, 3.6], [0, 0, 0]], [[-1.0, 1.5, 3.0], [0.3, 0, 0]], [[1.9, 0.95, 2.3], [0.6, 0, -0.2]]], + bragg: [[[0, 9, 8], [0, 0, 0]], [[-3.2, 4.8, 5.2], [0.3, 0, 0]], [[-5.2, 2.8, 2.1], [0.5, 0, 0]]], + }; + const camCurves = {}; + for (const [k, keys] of Object.entries(CITY_CAMS)) { + const s = PORTRAIT ? 1.18 : 1; + camCurves[k] = { + pos: new THREE.CatmullRomCurve3(keys.map(([p, q]) => new THREE.Vector3(q[0] + (p[0] - q[0]) * s, q[1] + (p[1] - q[1]) * s, q[2] + (p[2] - q[2]) * s))), + tgt: new THREE.CatmullRomCurve3(keys.map(([, q]) => new THREE.Vector3(...q))), + }; + } + const KMV = 0.001; + const cv = new THREE.Vector3(); + function projectCity(x, y, z = 0) { + cv.set(x * KMV, z * KMV, -y * KMV).project(cityCam); + return { x: (cv.x * 0.5 + 0.5) * W, y: (-cv.y * 0.5 + 0.5) * H, vis: cv.z < 1 && cv.z > -1 }; + } + + const hud = createHUD({ W, H, PORTRAIT, heroes, cams, counts, rapidList, pick, capT, snapT, sunLL, flurry, nycCams }); // ---------------------------------------------------------------- camera director // Keys: [t, lon, lat, g (globe radius / half the short side), shiftX, shiftY, roll] @@ -293,24 +347,54 @@ async function init() { [8.7, 0, 0, 0.6, 0, 0, 0], [16.6, -1.5, 1.5, 0.62, 0, 0, 0], [19.5, 4, 6, 0.7, 0, 0, -0.01], - [24.5, 18, 16, 0.75, 0, 0, -0.02], - [30.0, 24, 36, 0.98, sx * 0.5, sy * 0.5, -0.02], - [32.5, 14, 58, 1.85, sx, sy, -0.03], - [33.7, 8, 59, 1.9, sx, sy, -0.03], - [35.9, -55, 43, 1.0, sx, sy, -0.01], - [37.2, -82, 38, 0.92, sx, sy, 0.0], - [40.0, -110, 26, 0.42, sx, sy, 0.02], - [42.6, -150, 18, 0.62, sx, sy, 0.02], - [44.4, -190, 2, 0.7, sx, sy, 0.01], - [46.4, -215, 4, 0.78, sx, sy, 0.0], - [49.0, -228, 14, 0.85, sx, sy, 0.0], - [52.4, -236, 20, 0.85, sx * 0.4, sy * 0.4, 0.0], - [56.0, -258, 24, 0.5, 0, 0, 0.0], - [60.5, -288, 22, 0.16, 0, 0, 0.0], + [22.6, 14, 12, 0.74, 0, 0, -0.02], + // the flurry: the planet whips a full turn, accelerating then braking + [23.6, 20, 12, 0.74, 0, 0, -0.02], + [25.0, 50, 14, 0.74, 0, 0, -0.03], + [26.2, 120, 16, 0.72, 0, 0, -0.04], + [27.2, 230, 18, 0.72, 0, 0, -0.04], + [28.2, 330, 24, 0.78, 0, 0, -0.03], + [29.2, 375, 30, 0.9, sx * 0.3, sy * 0.3, -0.02], + [30.0, 384, 36, 0.98, sx * 0.5, sy * 0.5, -0.02], + [31.8, 374, 58, 1.85, sx, sy, -0.03], + [32.8, 368, 59, 1.9, sx, sy, -0.03], + [34.4, 305, 43, 1.0, sx, sy, -0.01], + [35.6, 268, 30, 0.5, sx, sy, 0.0], + [37.6, 225, 20, 0.62, sx, sy, 0.02], + [39.4, 175, 2, 0.7, sx, sy, 0.01], + [41.0, 150, 6, 0.78, sx, sy, 0.0], + // descent: swing to the Himalaya and fall in + [42.4, 100, 22, 1.1, sx * 0.4, sy * 0.4, 0.0], + [43.2, 88, 27.5, 3.5, 0, 0, 0.0], + [43.7, 87, 28, 9.0, 0, 0, 0.0], + // behind the camera wall, and back out for saturation + [71.0, 205, 16, 0.9, 0, 0, 0.0], + [76.4, 200, 18, 0.9, 0, 0, 0.0], + [80.0, 178, 24, 0.5, 0, 0, 0.0], + [84.4, 148, 22, 0.16, 0, 0, 0.0], ]; // Title framing: the planet becomes the iris of the logo. const eye = hud.eyeLayout; + // Each flurry capture fires as its real location rotates into view on the + // leading (east) side of the disc during the spin (18° east of centre). + { + const samples = []; + for (let i = 0; i <= 600; i++) { + const tt = T.flurry + (i / 600) * (T.flurryEnd - T.flurry); + samples.push([tt, camAt(tt).lon]); + } + const lo = samples[0][1]; + const hi = samples[samples.length - 1][1]; + for (const f of flurry) { + let L = f.lon - 18; + while (L < lo) L += 360; + if (L > hi) continue; + const s = samples.find((q) => q[1] >= L); + if (s) f.t = s[0] + (hash01(f.lat * 13.3) - 0.5) * 0.08; + } + } + function camAt(t) { if (t >= T.title - 0.05) { const g = eye.irisR / (Math.min(W, H) / 2); @@ -439,6 +523,7 @@ async function init() { function renderFrame(frame) { const t = frame / FPS; const cam = placeCamera(t); + const cityNow = cityScenes.find((c) => t >= c.t0 && t < c.t1); const G = globe.uniforms; const front = frontAngle(t); const titleOn = t >= T.title - 0.05; @@ -574,6 +659,30 @@ async function init() { S.camPos = camera.position; S.latticeR = latticePx(t, cam.dist); S.ringR = ringR0; + let renderScene = scene; + let renderCam = camera; + if (cityNow) { + const lt = t - cityNow.t0; + const k = (t - cityNow.t0) / (cityNow.t1 - cityNow.t0); + const u = 1 - Math.pow(1 - k, 2.2); + const cc = camCurves[cityNow.slug]; + cityCam.position.copy(cc.pos.getPoint(u)); + const tg = cc.tgt.getPoint(u); + // a hair of handheld drift + cityCam.position.x += Math.sin(t * 0.7) * 0.01; + cityCam.position.y += Math.sin(t * 0.53) * 0.008; + cityCam.lookAt(tg); + const sh = impulse(t, 9) * 0.003; + cityCam.rotateZ(Math.sin(t * 91) * sh); + cityCam.updateMatrixWorld(); + cityNow.built.update(lt, k, pxScale); + // Boston is the transit showcase: let the GTFS lines lead + if (cityNow.slug === 'boston' && cityNow.built.roads) cityNow.built.roads.mesh.material.uniforms.uAlpha.value = 0.45; + renderScene = cityNow.built.scene; + renderCam = cityCam; + S.city = { ...cityNow, lt, k, project: projectCity, built: cityNow.built }; + S.cam = { ...S.cam, lat: cityNow.lat, lon: cityNow.lon, dist: 1 + cityCam.position.y / 6371 }; + } hud.draw(S); // ---- post @@ -585,7 +694,9 @@ async function init() { win(t, T.cctvOut - 0.05, T.cctvOut + 0.3, 0.02, 0.25) * 0.8 + win(t, T.dishBreak - 0.02, T.dishBreak + 0.4, 0.02, 0.3) * 1.0 + win(t, T.cut - 0.25, T.cut, 0.1, 0.01) * 1.0 + - win(t, T.eyeOpen - 0.02, T.eyeOpen + 0.2, 0.02, 0.15) * 0.4; + win(t, T.eyeOpen - 0.02, T.eyeOpen + 0.2, 0.02, 0.15) * 0.4 + + win(t, T.terrain - 0.35, T.terrain + 0.25, 0.25, 0.2) * 1.0 + + CITIES.slice(1).reduce((s, c) => s + win(t, c.t0 - 0.06, c.t0 + 0.22, 0.04, 0.16) * 0.8, 0); const flash = Math.exp(-Math.max(0, t - T.substrate) * 12) * 0.035 * (t >= T.substrate ? 1 : 0) + Math.exp(-Math.max(0, t - T.indexed) * 12) * 0.05 * (t >= T.indexed ? 1 : 0) + @@ -594,7 +705,7 @@ async function init() { let fade = smooth(t, 0, 0.6); if (t >= T.cut && t < T.title) fade = 0; fade *= 1 - smooth(t, T.fadeOut, DURATION_END); - post.render(scene, camera, { + post.render(renderScene, renderCam, { time: t, frame, bloom: t < T.seed ? 0.7 : lerp(0.85, 1.15, smooth(t, T.saturate, T.cut)), @@ -646,7 +757,39 @@ async function init() { if (qs.has('play')) startPlayback(renderFrame); } -const DURATION_END = 72; +const DURATION_END = DURATION; +const hash01 = (n) => { + const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return s - Math.floor(s); +}; + +// Modelled actors (always labelled MODELED in the HUD) where real feeds are thin. +function modeledFor(slug, d) { + const out = []; + if (slug === 'nyc') { + const realHelis = (d.air?.a || []).filter((p) => p[8] === 'A7').length; + if (realHelis < 3) { + // Hudson and East River corridors, and a hover orbit over Midtown + out.push({ name: 'HUDSON CORRIDOR', path: [[-1750, -3300, 300], [-1950, -1000, 330], [-1800, 1500, 350], [-1450, 3300, 320], [-1250, 1500, 300], [-1400, -1000, 290]], speed: 0.09, phase: 0.1, color: '#7dffd6' }); + out.push({ name: 'EAST RIVER', path: [[1650, -3200, 260], [1900, -800, 280], [2150, 1800, 300], [1750, 2600, 300], [1450, 800, 270], [1350, -1500, 250]], speed: 0.08, phase: 0.55, color: '#7dffd6' }); + out.push({ name: 'MIDTOWN ORBIT', path: loopPath(-150, 900, 420, 380, 450, 0.3, 40), speed: 0.16, phase: 0.2, color: '#7dffd6' }); + } + } + if (slug === 'bragg') { + const rw = d.military?.runways?.[0]; + let cx = 0, cy = 0, rot = 0; + if (rw) { + const x1 = rw[2], y1 = rw[3], x2 = rw[rw.length - 2], y2 = rw[rw.length - 1]; + cx = (x1 + x2) / 2; + cy = (y1 + y2) / 2; + rot = Math.atan2(y2 - y1, x2 - x1); + } + out.push({ name: 'PATTERN', path: loopPath(cx, cy, 2600, 1100, 330, rot, 48), speed: 0.07, phase: 0.0, color: '#ffb547' }); + out.push({ name: 'PATTERN', path: loopPath(cx, cy, 2600, 1100, 330, rot, 48), speed: 0.07, phase: 0.5, color: '#ffb547' }); + out.push({ name: 'ROTARY', path: loopPath(cx - 3000, cy + 2500, 900, 700, 180, 0.4, 32), speed: 0.11, phase: 0.3, color: '#ffb547' }); + } + return out; +} // Real-time mode (?play): the same deterministic frames, driven by the audio // clock, for use as a live loading sequence on a GPU. Browsers block audio diff --git a/intro-sequence/src/timeline.js b/intro-sequence/src/timeline.js index f9247be2e..189f41976 100644 --- a/intro-sequence/src/timeline.js +++ b/intro-sequence/src/timeline.js @@ -3,7 +3,7 @@ // stay frame-locked. Pure module: no DOM, safe to import from Node. export const FPS = 30; -export const DURATION = 72; +export const DURATION = 95; export const FRAMES = FPS * DURATION; export const T = { @@ -16,50 +16,61 @@ export const T = { beat2: 7.35, dishDraw: 6.3, dishDone: 8.3, + aperture: 7.35, - // ACT 1 — the culture grows; the growth turns out to be drawing the planet + // ACT 1 — the eye opens; the culture grows and turns out to be drawing the planet growStart: 8.7, coastReached: 13.4, growEnd: 15.6, substrate: 16.0, - // ACT 2 — indexing the surface + // ACT 2 — indexing the surface, accelerating until it is whipping round the world indexStart: 17.0, - zWave: [17.0, 19.0, 20.8], // z3, z4, z5 quadtree waves - capStart: 18.3, - capRapid: 23.4, - capFull: 26.6, + zWave: [17.0, 18.5, 19.8], // z3, z4, z5 quadtree waves + capStart: 18.0, + capRapid: 22.6, + flurry: 23.6, + flurryEnd: 29.4, + capFull: 999, // v1's full-frame extraction now lives in the city act indexed: 30.0, - // ACT 3 — ingestion, one feed at a time, accelerating + // ACT 3 — global ingestion air: 30.6, - sea: 32.2, - mil: 34.2, - orbit: 37.0, - quake: 40.2, - fire: 42.2, - wind: 44.0, - cable: 45.9, - cctv: 47.4, - cctvWall: 49.0, - rapid: 50.4, - cctvOut: 52.4, + sea: 32.0, + mil: 33.4, + orbit: 34.6, + quake: 36.6, + fire: 38.0, + wind: 39.4, + cable: 41.0, + descend: 42.6, - // ACT 4 — saturation; the twin outgrows the dish - saturate: 52.6, - arcs: 53.4, - twin: 55.2, - dishBreak: 58.4, - omni: 59.0, - cut: 60.5, + // ACT 4 — down into the world + terrain: 43.6, + nyc: 49.0, + boston: 57.0, + lax: 61.8, + bragg: 66.4, + cctv: 71.4, + cctvWall: 71.4, + rapid: 72.8, + cctvOut: 76.4, - // ACT 5 — resolve - title: 61.2, - eyeOpen: 62.0, - wordmark: 63.3, - tagline: 65.0, - online: 66.6, - fadeOut: 70.0, + // ACT 5 — saturation; the twin outgrows the dish + saturate: 76.6, + arcs: 77.3, + twin: 79.1, + dishBreak: 82.3, + omni: 82.9, + cut: 84.4, + + // ACT 6 — resolve + title: 85.1, + eyeOpen: 85.9, + wordmark: 87.2, + tagline: 88.9, + online: 999, + fadeOut: 93.0, }; // Feeds as they register in the stack (ACT 3). `key` matches a layer. @@ -72,7 +83,15 @@ export const FEEDS = [ { key: 'fire', t: T.fire, code: 'IR', label: 'ACTIVE FIRES', src: 'NASA FIRMS' }, { key: 'wind', t: T.wind, code: 'NWP', label: 'WIND · CLOUD', src: 'NOAA GFS · GIBS' }, { key: 'cable', t: T.cable, code: 'GEO', label: 'SUBSEA CABLES', src: 'TELEGEOGRAPHY' }, - { key: 'cctv', t: T.cctv, code: 'OPT', label: 'PUBLIC CAMERAS', src: '18 AGENCIES' }, +]; + +// ACT 4 — the cities, in order +export const CITIES = [ + { slug: 'everest', t0: T.terrain, t1: T.nyc, name: 'MOUNT EVEREST', sub: 'KHUMBU · NEPAL / TIBET', lat: 27.9881, lon: 86.925 }, + { slug: 'nyc', t0: T.nyc, t1: T.boston, name: 'NEW YORK', sub: 'MIDTOWN · MANHATTAN', lat: 40.74, lon: -73.99 }, + { slug: 'boston', t0: T.boston, t1: T.lax, name: 'BOSTON', sub: 'MBTA · GTFS REALTIME', lat: 42.3555, lon: -71.0605 }, + { slug: 'lax', t0: T.lax, t1: T.bragg, name: 'LOS ANGELES INTL', sub: 'KLAX · AIRSIDE', lat: 33.9425, lon: -118.4081 }, + { slug: 'bragg', t0: T.bragg, t1: T.cctv, name: 'FORT BRAGG', sub: 'NORTH CAROLINA · POPE FIELD', lat: 35.158, lon: -79.009 }, ]; // Synthetic captures in ACT 2: the first few take the slow "enhance" path, the @@ -80,7 +99,7 @@ export const FEEDS = [ export const CAPTURE_SLOW = 3; export function captureTimes(count) { const out = []; - const slot = [T.capStart, T.capStart + 1.75, T.capStart + 3.35]; + const slot = [T.capStart, T.capStart + 1.6, T.capStart + 3.1]; for (let i = 0; i < Math.min(CAPTURE_SLOW, count); i++) out.push(slot[i]); const rapidN = count - CAPTURE_SLOW; // Accelerating rapid-fire: intervals shrink from ~0.32 s to ~0.11 s. @@ -88,7 +107,7 @@ export function captureTimes(count) { for (let i = 0; i < rapidN; i++) { out.push(t); const k = i / Math.max(1, rapidN - 1); - t += 0.32 - 0.21 * k; + t += 0.12 - 0.08 * k; } return out; } diff --git a/intro-sequence/tools/city/check.py b/intro-sequence/tools/city/check.py new file mode 100644 index 000000000..2d559230f --- /dev/null +++ b/intro-sequence/tools/city/check.py @@ -0,0 +1,79 @@ +"""Render city//check.png: ground imagery (left) and imagery + OSM overlay (right) for alignment QA. + +usage: python3 -I tools/city/check.py nyc boston [--crop x y size] +""" +import sys, os, json +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from PIL import Image, ImageDraw +from geo import CITIES, OUT, CACHE + +ROADC = [(255, 60, 60), (255, 150, 40), (255, 220, 40), (200, 255, 80), (255, 255, 255), (160, 160, 160)] + + +def render(slug, S=1400, crop=None): + d = os.path.join(OUT, slug) + h = CITIES[slug]['half'] + cx, cy, cs = crop if crop else (0, 0, h) # center and half-size of view in meters + g = Image.open(os.path.join(d, 'ground.jpg')).convert('RGB') + G = g.size[0] + # crop region of ground in pixels + l = (cx - cs + h) / (2 * h) * G + t = (h - (cy + cs)) / (2 * h) * G + r = (cx + cs + h) / (2 * h) * G + b = (h - (cy - cs)) / (2 * h) * G + base = g.crop((int(l), int(t), int(r), int(b))).resize((S, S), Image.LANCZOS) + + def P(x, y): + return ((x - (cx - cs)) / (2 * cs) * S, ((cy + cs) - y) / (2 * cs) * S) + + ov = base.copy() + dr = ImageDraw.Draw(ov, 'RGBA') + wp = os.path.join(d, 'water.json') + if os.path.exists(wp): + for poly in json.load(open(wp))['polys']: + for k, ring in enumerate(poly): + pts = [P(ring[i], ring[i + 1]) for i in range(0, len(ring), 2)] + if len(pts) >= 3: + dr.polygon(pts, outline=(60, 160, 255, 255), fill=(30, 90, 255, 70) if k == 0 else None) + for bld in json.load(open(os.path.join(d, 'buildings.json')))['b']: + hgt = bld[0] + pts = [P(bld[i], bld[i + 1]) for i in range(2, len(bld), 2)] + a = min(255, 60 + int(hgt)) + col = (0, 255, 200, 255) if hgt >= 100 else (255, 0, 220, 255) + dr.polygon(pts, outline=col, fill=(col[0], col[1], col[2], 40 if hgt < 100 else 90)) + for rd in json.load(open(os.path.join(d, 'roads.json')))['roads']: + pts = [P(rd[i], rd[i + 1]) for i in range(3, len(rd), 2)] + dr.line(pts, fill=ROADC[rd[0]] + (230,), width=2 if rd[0] < 3 else 1) + rp = os.path.join(d, 'rail.json') + if os.path.exists(rp): + for ln in json.load(open(rp))['lines']: + c = ln[0] + col = tuple(int(c[i:i + 2], 16) for i in (1, 3, 5)) + (255,) + pts = [P(ln[i], ln[i + 1]) for i in range(1, len(ln), 2)] + dr.line(pts, fill=col, width=3) + ep = os.path.join(d, 'extra.json') + if os.path.exists(ep): + for x, y, nm in json.load(open(ep))['helipads']: + px, py = P(x, y) + dr.ellipse((px - 7, py - 7, px + 7, py + 7), outline=(255, 255, 0, 255), width=3) + out = Image.new('RGB', (S * 2 + 10, S), (0, 0, 0)) + out.paste(base, (0, 0)) + out.paste(ov, (S + 10, 0)) + return out + + +if __name__ == '__main__': + args = [a for a in sys.argv[1:] if not a.startswith('--')] + crop = None + if '--crop' in sys.argv: + i = sys.argv.index('--crop') + crop = tuple(float(v) for v in sys.argv[i + 1:i + 4]) + args = [a for a in args if a not in sys.argv[i + 1:i + 4]] + for slug in args or list(CITIES): + im = render(slug, crop=crop) + if crop: + p = os.path.join(CACHE, f'{slug}_check_crop_{int(crop[0])}_{int(crop[1])}_{int(crop[2])}.png') + else: + p = os.path.join(OUT, slug, 'check.png') + im.save(p, optimize=True) + print('wrote', p) diff --git a/intro-sequence/tools/city/fetch_imagery.py b/intro-sequence/tools/city/fetch_imagery.py new file mode 100644 index 000000000..c4fa70fd3 --- /dev/null +++ b/intro-sequence/tools/city/fetch_imagery.py @@ -0,0 +1,107 @@ +"""Esri World Imagery mosaic -> local ENU square, 4096^2 + 2048^2 JPEG. + +usage: python3 -I tools/city/fetch_imagery.py nyc boston [--zoom 17] +""" +import sys, os, math, io, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from concurrent.futures import ThreadPoolExecutor +import numpy as np +import requests +from PIL import Image +from scipy.ndimage import map_coordinates +from geo import CITIES, OUT, CACHE, Frame + +URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' +TS = 256 +N = 4096 +SS = 2 # supersample factor before Lanczos downsample + + +def merc_px(lat, lon, z): + s = TS * 2 ** z + x = (lon + 180.0) / 360.0 * s + lr = np.radians(lat) + y = (1.0 - np.log(np.tan(lr) + 1.0 / np.cos(lr)) / math.pi) / 2.0 * s + return x, y + + +sess = requests.Session() +sess.headers['User-Agent'] = 'gods-eye-view-intro/1.0 (film asset build)' + + +def get_tile(z, x, y): + d = os.path.join(CACHE, 'tiles', str(z), str(x)) + os.makedirs(d, exist_ok=True) + p = os.path.join(d, f'{y}.jpg') + if os.path.exists(p) and os.path.getsize(p) > 0: + return p, 'cache' + last = None + for attempt in range(5): + try: + r = sess.get(URL.format(z=z, x=x, y=y), timeout=60) + if r.status_code == 200 and r.content[:2] in (b'\xff\xd8', b'\x89P'): + with open(p, 'wb') as f: + f.write(r.content) + return p, 'net' + last = f'HTTP {r.status_code} {r.headers.get("content-type")}' + except Exception as e: # noqa + last = str(e)[:120] + time.sleep(1.5 * (attempt + 1)) + raise RuntimeError(f'tile {z}/{x}/{y} failed: {last}') + + +def build(slug, z): + c = CITIES[slug] + F = Frame(**c) + h = F.half + s, w, n, e = F.bbox(h * 1.02) + x0, y0 = merc_px(n, w, z) + x1, y1 = merc_px(s, e, z) + tx0, ty0 = int(x0 // TS), int(y0 // TS) + tx1, ty1 = int(x1 // TS), int(y1 // TS) + tiles = [(tx, ty) for ty in range(ty0, ty1 + 1) for tx in range(tx0, tx1 + 1)] + print(f'== {slug} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles') + with ThreadPoolExecutor(8) as ex: + res = list(ex.map(lambda t: get_tile(z, t[0], t[1]), tiles)) + print(f' net {sum(1 for r in res if r[1]=="net")} cache {sum(1 for r in res if r[1]=="cache")}') + W, H = (tx1 - tx0 + 1) * TS, (ty1 - ty0 + 1) * TS + mos = np.zeros((H, W, 3), np.uint8) + for (tx, ty), (p, _) in zip(tiles, res): + im = np.asarray(Image.open(p).convert('RGB')) + mos[(ty - ty0) * TS:(ty - ty0 + 1) * TS, (tx - tx0) * TS:(tx - tx0 + 1) * TS] = im + M = N * SS + t = (np.arange(M) + 0.5) / M + xs = -h + 2 * h * t # east + ys = h - 2 * h * t # north, row 0 at top + lon = F.lon0 + xs / F.kx + lat = F.lat0 + ys / F.ky + px, _ = merc_px(np.full_like(lon, F.lat0), lon, z) + _, py = merc_px(lat, np.full_like(lat, F.lon0), z) + px = px - tx0 * TS - 0.5 # pixel centers + py = py - ty0 * TS - 0.5 + PX, PY = np.meshgrid(px, py) + out = np.empty((M, M, 3), np.uint8) + for ch in range(3): + out[..., ch] = np.clip(map_coordinates(mos[..., ch].astype(np.float32), [PY, PX], order=1, + mode='nearest'), 0, 255).astype(np.uint8) + del PX, PY + img = Image.fromarray(out) + if SS != 1: + img = img.resize((N, N), Image.LANCZOS) + d = os.path.join(OUT, slug) + os.makedirs(d, exist_ok=True) + img.save(os.path.join(d, 'ground.jpg'), quality=90, optimize=True) + img.resize((2048, 2048), Image.LANCZOS).save(os.path.join(d, 'ground_2k.jpg'), quality=90, optimize=True) + img.resize((768, 768), Image.LANCZOS).save(os.path.join(CACHE, f'{slug}_ground_preview.jpg'), quality=85) + mpp_src = 156543.03392 * math.cos(math.radians(F.lat0)) / 2 ** z + print(f' wrote ground.jpg {os.path.getsize(os.path.join(d, "ground.jpg"))/1e6:.1f}MB; ' + f'source {mpp_src:.2f} m/px, output {2*h/N:.2f} m/px') + + +if __name__ == '__main__': + args = [a for a in sys.argv[1:] if not a.startswith('--')] + z = 17 + if '--zoom' in sys.argv: + z = int(sys.argv[sys.argv.index('--zoom') + 1]) + for slug in args or list(CITIES): + build(slug, z) diff --git a/intro-sequence/tools/city/fetch_osm.py b/intro-sequence/tools/city/fetch_osm.py new file mode 100644 index 000000000..d442add87 --- /dev/null +++ b/intro-sequence/tools/city/fetch_osm.py @@ -0,0 +1,495 @@ +"""Fetch OSM buildings / roads / water / extras for the city scenes and write city//*.json. + +usage: python3 -I tools/city/fetch_osm.py nyc boston +""" +import sys, os, math, re, json +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from geo import (CITIES, OUT, Frame, overpass, rdp, rdp_ring, area, centroid, open_ring, + assemble_rings, clip_polyline, clip_polygon, flat, r1, write_json, dedupe) + +FT = 0.3048 + + +def parse_len(v): + """Parse an OSM length tag to meters. Returns None if unparseable.""" + if v is None: + return None + s = str(v).strip().lower().replace(',', '.') + s = s.split(';')[0].strip() + m = re.match(r"^(\d+(?:\.\d+)?)\s*'\s*(?:(\d+(?:\.\d+)?)\s*\"?)?$", s) # 12'6" + if m: + return float(m.group(1)) * FT + (float(m.group(2)) * 0.0254 if m.group(2) else 0) + m = re.match(r'^(-?\d+(?:\.\d+)?)\s*(m|meters?|metres?|ft|feet|foot)?$', s) + if not m: + return None + val = float(m.group(1)) + u = m.group(2) or 'm' + if u in ('ft', 'feet', 'foot'): + val *= FT + return val + + +def parse_num(v): + if v is None: + return None + m = re.match(r'^\s*(\d+(?:\.\d+)?)', str(v).replace(',', '.')) + return float(m.group(1)) if m else None + + +HOUSE = {'house', 'detached', 'semidetached_house', 'terrace', 'bungalow', 'cabin', 'shed', 'garage', + 'garages', 'hut', 'carport', 'static_caravan'} + + +def building_height(t): + """Returns (height, min_height, source) where source in h/lv/def.""" + h = parse_len(t.get('height')) + src = 'h' + if h is None or h <= 0: + lv = parse_num(t.get('building:levels')) + if lv is not None and lv > 0: + h = lv * 3.2 + (parse_num(t.get('roof:levels')) or 0) * 3.2 + src = 'lv' + else: + b = t.get('building', 'yes') + h = 7.0 if b in HOUSE else 12.0 + if b in ('shed', 'garage', 'garages', 'carport', 'hut'): + h = 3.5 + src = 'def' + mh = parse_len(t.get('min_height')) + if mh is None: + ml = parse_num(t.get('building:min_level')) + mh = ml * 3.2 if ml else 0.0 + mh = max(0.0, mh) + if mh >= h: + mh = 0.0 + return h, mh, src + + +def ll_list(geom): + return [(g['lat'], g['lon']) for g in geom if g is not None] + + +# ------------------------------------------------------------------ buildings + +def buildings(slug, F): + h = F.half + s, w, n, e = F.bbox(h + 60) + # tile the bbox to keep each response manageable + T = 2 if h > 3000 else 1 + elems = {} + for i in range(T): + for j in range(T): + ts = s + (n - s) * i / T + tn = s + (n - s) * (i + 1) / T + tw = w + (e - w) * j / T + te = w + (e - w) * (j + 1) / T + bb = f'{ts:.6f},{tw:.6f},{tn:.6f},{te:.6f}' + q = (f'[out:json][timeout:180];(way["building"]({bb});' + f'relation["building"]["type"="multipolygon"]({bb}););out geom;') + print(f' buildings tile {i},{j}') + d = overpass(q, f'{slug}_bld_{i}{j}') + for el in d['elements']: + elems[(el['type'], el['id'])] = el + print(f' {len(elems)} building elements') + out = [] + stats = dict(h=0, lv=0, df=0, dropped_small=0, dropped_out=0, rel_rings=0) + for (typ, _id), el in elems.items(): + t = el.get('tags', {}) + if t.get('building') in ('no', 'construction_site'): + continue + rings = [] + if typ == 'way': + if 'geometry' not in el: + continue + g = ll_list(el['geometry']) + if len(g) >= 4 and g[0] == g[-1]: + rings.append(g) + else: + outers = [ll_list(m['geometry']) for m in el.get('members', []) + if m['type'] == 'way' and m.get('role') == 'outer' and 'geometry' in m] + rings = assemble_rings(outers) + stats['rel_rings'] += len(rings) + hgt, mh, src = building_height(t) + for g in rings: + pts = open_ring([F.xy(la, lo) for la, lo in g]) + pts = rdp_ring(pts, 0.5) + if len(pts) < 3 or abs(area(pts)) < 8: + stats['dropped_small'] += 1 + continue + cx, cy = centroid(pts) + if abs(cx) > h or abs(cy) > h: + stats['dropped_out'] += 1 + continue + stats[{'h': 'h', 'lv': 'lv', 'def': 'df'}[src]] += 1 + out.append((cx * cx + cy * cy, [r1(hgt), r1(mh)] + flat(pts))) + out.sort(key=lambda a: a[0]) + b = [o[1] for o in out] + write_json(os.path.join(OUT, slug, 'buildings.json'), {'count': len(b), 'b': b}) + hs = sorted(x[0] for x in b) + print(f' buildings {len(b)} height-src tag={stats["h"]} levels={stats["lv"]} default={stats["df"]} ' + f'dropped small={stats["dropped_small"]} outside={stats["dropped_out"]} tallest={hs[-5:]}') + return dict(count=len(b), height_from_tag=stats['h'], height_from_levels=stats['lv'], + height_default=stats['df'], tallest_m=hs[-1] if hs else None) + + +# ------------------------------------------------------------------ roads + +CLS = {'motorway': 0, 'trunk': 0, 'primary': 1, 'secondary': 2, 'tertiary': 3, + 'residential': 4, 'unclassified': 4, 'service': 5} +DEF_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1} + + +def plen(pts): + return sum(math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) for i in range(len(pts) - 1)) + + +def roads(slug, F): + h = F.half + s, w, n, e = F.bbox(h + 100) + bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}' + q = (f'[out:json][timeout:180];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|residential|' + f'unclassified|service|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link)$"]({bb});out geom;') + print(' roads') + d = overpass(q, f'{slug}_roads') + out = [] + cnt = [0] * 6 + tunnels = 0 + short_service = 0 + for el in d['elements']: + t = el.get('tags', {}) + hw = t['highway'] + if t.get('area') == 'yes': + continue + if t.get('tunnel') in ('yes', 'covered') or t.get('layer', '0').startswith('-') and t.get('tunnel'): + tunnels += 1 + continue + pts = [F.xy(la, lo) for la, lo in ll_list(el.get('geometry', []))] + pts = dedupe(pts) + if len(pts) < 2: + continue + if hw.endswith('_link'): + c = 5 + else: + c = CLS[hw] + if hw == 'service' and plen(pts) <= 80: + short_service += 1 + continue + ow = t.get('oneway', '') + oneway = 0 + if ow in ('yes', '1', 'true'): + oneway = 1 + elif ow == '-1': + oneway = 1 + pts = pts[::-1] + elif ow == 'no': + oneway = 0 + elif hw in ('motorway', 'motorway_link') or t.get('junction') in ('roundabout', 'circular'): + oneway = 1 + ln = parse_num(t.get('lanes')) + lanes = int(ln) if ln else DEF_LANES[c] + for piece in clip_polyline(pts, -h, -h, h, h): + piece = rdp(piece, 1.0) + if len(piece) < 2 or plen(piece) < 1: + continue + out.append([c, oneway, lanes] + flat(piece)) + cnt[c] += 1 + write_json(os.path.join(OUT, slug, 'roads.json'), {'roads': out}) + print(f' roads {len(out)} by class {cnt} (dropped tunnels {tunnels}, short service {short_service})') + return dict(count=len(out), by_class=cnt, dropped_tunnel_ways=tunnels, dropped_short_service=short_service) + + +# ------------------------------------------------------------------ water + +def coast_water(slug, F, h): + """Build water polygons inside the square from natural=coastline (land left, water right).""" + s, w, n, e = F.bbox(h * 1.6) + bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}' + q = f'[out:json][timeout:180];way["natural"="coastline"]({bb});out geom;' + print(' coastline') + d = overpass(q, f'{slug}_coast') + ways = [[F.xy(la, lo) for la, lo in ll_list(el['geometry'])] for el in d['elements'] if 'geometry' in el] + # directional merge into chains + chains = [] + pool = [wy for wy in ways if len(wy) >= 2] + while pool: + cur = pool.pop() + grown = True + while grown and cur[0] != cur[-1]: + grown = False + for i, wy in enumerate(pool): + if wy[0] == cur[-1]: + cur = cur + wy[1:] + elif wy[-1] == cur[0]: + cur = wy + cur[1:] + else: + continue + pool.pop(i) + grown = True + break + chains.append(cur) + eps = 1e-6 + + def on_edge(p): + return abs(abs(p[0]) - h) < 1e-3 or abs(abs(p[1]) - h) < 1e-3 + + def spos(p): # clockwise perimeter coordinate from top-left + x, y = p + if abs(y - h) < 1e-3: + return x + h + if abs(x - h) < 1e-3: + return 2 * h + (h - y) + if abs(y + h) < 1e-3: + return 4 * h + (h - x) + return 6 * h + (y + h) + + corners = [(0.0, (-h, h)), (2 * h, (h, h)), (4 * h, (h, -h)), (6 * h, (-h, -h))] + pieces, islands, bad = [], [], 0 + for ch in chains: + closed = ch[0] == ch[-1] + inside_all = all(abs(x) <= h and abs(y) <= h for x, y in ch) + if closed and inside_all: + islands.append(open_ring(ch)) + continue + for pc in clip_polyline(ch, -h, -h, h, h): + if on_edge(pc[0]) and on_edge(pc[-1]): + pieces.append(pc) + else: + bad += 1 + polys = [] + if pieces: + used = [False] * len(pieces) + starts = [spos(p[0]) for p in pieces] + for k in range(len(pieces)): + if used[k]: + continue + ring = [] + cur = k + guard = 0 + while True: + used[cur] = True + ring += pieces[cur] + se = spos(pieces[cur][-1]) + # next entry clockwise from se + best, bd = None, None + for m in range(len(pieces)): + dd = (starts[m] - se) % (8 * h) + if bd is None or dd < bd: + best, bd = m, dd + # add corners passed between se and se+bd + for cs, cp in sorted(corners, key=lambda c: (c[0] - se) % (8 * h)): + if 0 < (cs - se) % (8 * h) < bd: + ring.append(cp) + cur = best + guard += 1 + if cur == k or guard > len(pieces) + 2: + break + if used[cur]: + break + polys.append([dedupe(open_ring(ring))]) + # attach islands (land holes) to the containing water polygon + def pip(pt, ring): + x, y = pt + c = False + for i in range(len(ring)): + x1, y1 = ring[i] + x2, y2 = ring[i - 1] + if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1: + c = not c + return c + for isl in islands: + if abs(area(isl)) < 50: + continue + for p in polys: + if pip(isl[0], p[0]): + p.append(isl) + break + print(f' coastline chains {len(chains)} pieces {len(pieces)} islands {len(islands)} ' + f'water polys {len(polys)} (unclosed pieces skipped {bad})') + return polys, bad + + +def water(slug, F): + h = F.half + s, w, n, e = F.bbox(h + 50) + bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}' + q = (f'[out:json][timeout:180];(way["natural"="water"]({bb});relation["natural"="water"]({bb});' + f'way["waterway"="riverbank"]({bb});relation["waterway"="riverbank"]({bb}););out geom;') + print(' water areas') + d = overpass(q, f'{slug}_water') + polys = [] + for el in d['elements']: + t = el.get('tags', {}) + if t.get('water') in ('pond', 'reflecting_pool', 'fountain', 'basin', 'reservoir') and el['type'] == 'way': + pass # keep small ponds too; they are real water + if el['type'] == 'way': + if 'geometry' not in el: + continue + g = ll_list(el['geometry']) + if g[0] != g[-1]: + continue + outers, inners = [g], [] + else: + mem = [m for m in el.get('members', []) if m['type'] == 'way' and 'geometry' in m] + outers = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'outer']) + inners = assemble_rings([ll_list(m['geometry']) for m in mem if m.get('role') == 'inner']) + inn_xy = [open_ring([F.xy(*p) for p in r]) for r in inners] + for o in outers: + ring = open_ring([F.xy(*p) for p in o]) + ring = clip_polygon(ring, -h, -h, h, h) + ring = rdp_ring(open_ring(ring), 1.0) + if len(ring) < 3 or abs(area(ring)) < 20: + continue + p = [ring] + for ir in inn_xy: + ir2 = rdp_ring(open_ring(clip_polygon(ir, -h, -h, h, h)), 1.0) + if len(ir2) >= 3 and abs(area(ir2)) >= 20: + p.append(ir2) + polys.append(('water', p)) + n_area = len(polys) + cpolys, bad = coast_water(slug, F, h) + for p in cpolys: + p2 = [rdp_ring(r, 1.0) for r in p] + p2 = [r for r in p2 if len(r) >= 3] + if p2: + polys.append(('coast', p2)) + out = [[flat(r) for r in p] for _, p in polys] + src = [s_ for s_, _ in polys] + write_json(os.path.join(OUT, slug, 'water.json'), + {'count': len(out), 'format': 'polys[i] = [outerRing, holeRing...], rings flat x,y', 'src': src, + 'polys': out}) + print(f' water polys {len(out)} (osm water areas {n_area}, coastline-derived {len(cpolys)})') + return dict(count=len(out), water_areas=n_area, coastline_polys=len(cpolys), coastline_unclosed_pieces=bad) + + +# ------------------------------------------------------------------ extras + +KNOWN_HELI = ['Downtown Manhattan Heliport', 'West 30th Street Heliport', 'East 34th Street Heliport'] + + +def nyc_extra(slug, F): + s, w, n, e = F.bbox(F.half + 2000) + bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}' + q = (f'[out:json][timeout:180];(nwr["aeroway"~"^(helipad|heliport)$"]({bb});' + f'nwr["name"~"heliport|helistop|helipad",i]({bb}););out center tags;') + print(' helipads') + d = overpass(q, f'{slug}_heli2') + pads = [] + for el in d['elements']: + t = el.get('tags', {}) + if el['type'] == 'node': + la, lo = el['lat'], el['lon'] + else: + c = el.get('center') + if not c: + continue + la, lo = c['lat'], c['lon'] + x, y = F.xy(la, lo) + name = t.get('name') or t.get('operator') or '' + kind = t.get('aeroway') or ('heliport' if 'heliport' in name.lower() else 'named') + pads.append(dict(x=r1(x), y=r1(y), name=name, kind=kind, ref=t.get('ref', ''), osm=f"{el['type']}/{el['id']}", + tags={k: v for k, v in t.items() if k in ('aeroway', 'faa', 'operator', 'man_made', 'amenity')}, + inside=abs(x) <= F.half and abs(y) <= F.half)) + pads.sort(key=lambda p: (p['kind'] != 'heliport', p['x'] ** 2 + p['y'] ** 2)) + for p in pads: + print(f" {p['kind']:9s} {p['x']:8.1f} {p['y']:8.1f} {'in ' if p['inside'] else 'OUT'} {p['name']}") + found = {k: [p for p in pads if p['kind'] == 'heliport' and k.split()[0].lower() in p['name'].lower() + and 'heliport' in p['name'].lower()] for k in KNOWN_HELI} + helipads = [[p['x'], p['y'], p['name'] or p['kind']] for p in pads] + write_json(os.path.join(OUT, slug, 'extra.json'), + {'helipads': helipads, + 'detail': pads, + 'note': 'aeroway=helipad|heliport from OSM in a bbox 2 km wider than the scene; ' + 'detail[].inside says whether the point is inside the scene square'}) + return dict(helipad_points=len(pads), named_heliports={k: (v[0]['x'], v[0]['y'], v[0]['inside']) if v else None + for k, v in found.items()}) + + +def boston_rail(slug, F): + h = F.half + s, w, n, e = F.bbox(h + 100) + bb = f'{s:.6f},{w:.6f},{n:.6f},{e:.6f}' + q = (f'[out:json][timeout:180];(relation["route"~"^(subway|light_rail|train|tram)$"]({bb});' + f'way["railway"~"^(subway|light_rail|rail|tram)$"]({bb}););out geom;') + print(' rail') + d = overpass(q, f'{slug}_rail') + ways = {el['id']: el for el in d['elements'] if el['type'] == 'way'} + rels = [el for el in d['elements'] if el['type'] == 'relation'] + lines, seen = [], set() + rel_names = [] + for r in rels: + t = r.get('tags', {}) + col = (t.get('colour') or t.get('color') or '').strip() + if not re.match(r'^#?[0-9a-fA-F]{6}$', col): + col = {'subway': '#888888', 'light_rail': '#00843D', 'tram': '#00843D', 'train': '#80276C'}[t['route']] + if not col.startswith('#'): + col = '#' + col + col = col.upper() + rel_names.append(f"{t.get('route')}:{t.get('name', t.get('ref', ''))}:{col}") + for m in r.get('members', []): + if m['type'] != 'way' or 'geometry' not in m or m.get('role') in ('platform', 'stop'): + continue + wt = ways.get(m['ref'], {}).get('tags', {}) + if wt.get('public_transport') or wt.get('railway') == 'platform': + continue + key = (m['ref'], col) + if key in seen: + continue + seen.add(key) + pts = [F.xy(la, lo) for la, lo in ll_list(m['geometry'])] + for pc in clip_polyline(dedupe(pts), -h, -h, h, h): + pc = rdp(pc, 1.0) + if len(pc) >= 2: + lines.append([col] + flat(pc)) + covered = {k[0] for k in seen} + extra = 0 + for wid, el in ways.items(): + t = el.get('tags', {}) + if wid in covered or t.get('service') or 'geometry' not in el: + continue + pts = [F.xy(la, lo) for la, lo in ll_list(el['geometry'])] + for pc in clip_polyline(dedupe(pts), -h, -h, h, h): + pc = rdp(pc, 1.0) + if len(pc) >= 2: + lines.append(['#7F7F7F'] + flat(pc)) + extra += 1 + write_json(os.path.join(OUT, slug, 'rail.json'), + {'lines': lines, 'routes': sorted(set(rel_names)), + 'note': 'OSM route relations (subway/light_rail/train/tram) colour tags; #7F7F7F = railway way not in any route relation'}) + print(f' rail lines {len(lines)} from {len(rels)} route relations (+{extra} unassigned way pieces)') + for nm in sorted(set(rel_names)): + print(' ', nm) + return dict(polylines=len(lines), route_relations=len(rels), unassigned_pieces=extra) + + +def main(): + slugs = sys.argv[1:] or list(CITIES) + report = {} + for slug in slugs: + c = CITIES[slug] + F = Frame(**c) + print(f'== {slug}') + write_json(os.path.join(OUT, slug, 'meta.json'), + {'slug': slug, 'name': c['name'], 'lat0': c['lat0'], 'lon0': c['lon0'], 'half': c['half'], + 'frame': 'ENU m', + 'projection': 'x=(lon-lon0)*cos(lat0)*111320, y=(lat-lat0)*110540', + 'source': 'OpenStreetMap contributors (ODbL) via Overpass; imagery Esri World Imagery'}) + r = {} + r['buildings'] = buildings(slug, F) + r['roads'] = roads(slug, F) + try: + r['water'] = water(slug, F) + except Exception as ex: # noqa + print(' WATER FAILED', ex) + r['water'] = f'failed: {ex}' + if slug == 'nyc': + r['extra'] = nyc_extra(slug, F) + if slug == 'boston': + r['rail'] = boston_rail(slug, F) + report[slug] = r + print(json.dumps(report, indent=1)) + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cache', 'osm_report.json'), 'w') as f: + json.dump(report, f, indent=1) + + +if __name__ == '__main__': + main() diff --git a/intro-sequence/tools/city/geo.py b/intro-sequence/tools/city/geo.py new file mode 100644 index 000000000..fce78dc50 --- /dev/null +++ b/intro-sequence/tools/city/geo.py @@ -0,0 +1,288 @@ +"""Shared helpers for the city scene geodata fetchers (ENU frame, Overpass, geometry).""" +import json, math, os, time, hashlib +import requests + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, '..', '..')) +OUT = os.path.join(ROOT, 'city') +CACHE = os.path.join(HERE, 'cache') +os.makedirs(CACHE, exist_ok=True) + +CITIES = { + 'nyc': dict(name='New York — Midtown / Chelsea / Lower Manhattan', lat0=40.7400, lon0=-73.9900, half=3500), + 'boston': dict(name='Boston — Downtown / Back Bay / Cambridge / Seaport', lat0=42.3555, lon0=-71.0605, half=3200), +} + +OVERPASS = [ + 'https://overpass-api.de/api/interpreter', + 'https://overpass.kumi.systems/api/interpreter', + 'https://overpass.private.coffee/api/interpreter', + 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', +] + + +class Frame: + def __init__(self, lat0, lon0, half, **_): + self.lat0, self.lon0, self.half = lat0, lon0, half + self.kx = math.cos(math.radians(lat0)) * 111320.0 + self.ky = 110540.0 + + def xy(self, lat, lon): + return ((lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky) + + def ll(self, x, y): + return (self.lat0 + y / self.ky, self.lon0 + x / self.kx) + + def bbox(self, half=None): + h = self.half if half is None else half + s, w = self.ll(-h, -h) + n, e = self.ll(h, h) + return s, w, n, e + + +def overpass(query, tag): + """Run an Overpass query, caching the raw JSON response under cache/osm_.json.""" + path = os.path.join(CACHE, f'osm_{tag}.json') + if os.path.exists(path): + with open(path) as f: + return json.load(f) + last = None + for attempt in range(8): + for url in OVERPASS: + try: + t = time.time() + r = requests.post(url, data={'data': query}, timeout=400) + if r.status_code != 200: + last = f'{url} HTTP {r.status_code}' + print(' overpass fail', last) + continue + d = r.json() + if 'remark' in d and 'error' in d.get('remark', '').lower(): + last = f'{url} remark {d["remark"][:200]}' + print(' overpass remark', last) + continue + print(f' overpass ok {url} {len(r.content)/1e6:.1f}MB {time.time()-t:.0f}s {len(d["elements"])} el') + with open(path, 'w') as f: + json.dump(d, f) + return d + except Exception as e: # noqa + last = f'{url} {type(e).__name__}: {str(e)[:200]}' + print(' overpass fail', last) + time.sleep(15 + 15 * attempt) + raise RuntimeError('all overpass endpoints failed: ' + str(last)) + + +# ---------------- geometry ---------------- + +def rdp(pts, eps): + """Douglas-Peucker on an open polyline (list of (x,y)).""" + n = len(pts) + if n < 3: + return list(pts) + keep = [False] * n + keep[0] = keep[-1] = True + stack = [(0, n - 1)] + while stack: + a, b = stack.pop() + ax, ay = pts[a] + bx, by = pts[b] + dx, dy = bx - ax, by - ay + L = math.hypot(dx, dy) + dmax, idx = -1.0, -1 + for i in range(a + 1, b): + px, py = pts[i] + if L < 1e-9: + d = math.hypot(px - ax, py - ay) + else: + d = abs(dx * (ay - py) - dy * (ax - px)) / L + if d > dmax: + dmax, idx = d, i + if dmax > eps and idx > 0: + keep[idx] = True + stack.append((a, idx)) + stack.append((idx, b)) + return [p for p, k in zip(pts, keep) if k] + + +def rdp_ring(ring, eps): + """Douglas-Peucker for a closed ring given without the repeated closing vertex.""" + n = len(ring) + if n < 4: + return list(ring) + # split at vertex 0 and the vertex farthest from it + x0, y0 = ring[0] + far = max(range(n), key=lambda i: (ring[i][0] - x0) ** 2 + (ring[i][1] - y0) ** 2) + a = rdp(ring[0:far + 1], eps) + b = rdp(ring[far:] + [ring[0]], eps) + return a[:-1] + b[:-1] + + +def area(ring): + s = 0.0 + n = len(ring) + for i in range(n): + x1, y1 = ring[i] + x2, y2 = ring[(i + 1) % n] + s += x1 * y2 - x2 * y1 + return s / 2.0 + + +def centroid(ring): + a = area(ring) + if abs(a) < 1e-9: + xs = [p[0] for p in ring] + ys = [p[1] for p in ring] + return sum(xs) / len(xs), sum(ys) / len(ys) + cx = cy = 0.0 + n = len(ring) + for i in range(n): + x1, y1 = ring[i] + x2, y2 = ring[(i + 1) % n] + c = x1 * y2 - x2 * y1 + cx += (x1 + x2) * c + cy += (y1 + y2) * c + return cx / (6 * a), cy / (6 * a) + + +def dedupe(pts): + out = [] + for p in pts: + if not out or p != out[-1]: + out.append(p) + return out + + +def open_ring(pts): + pts = dedupe(pts) + if len(pts) > 1 and pts[0] == pts[-1]: + pts = pts[:-1] + return pts + + +def assemble_rings(ways): + """Join way coordinate lists (lists of (lat,lon) tuples) into closed rings by matching endpoints.""" + rings = [] + pool = [list(w) for w in ways if len(w) >= 2] + while pool: + cur = pool.pop() + changed = True + while cur[0] != cur[-1] and changed: + changed = False + for i, w in enumerate(pool): + if w[0] == cur[-1]: + cur = cur + w[1:] + elif w[-1] == cur[-1]: + cur = cur + w[::-1][1:] + elif w[-1] == cur[0]: + cur = w + cur[1:] + elif w[0] == cur[0]: + cur = w[::-1] + cur[1:] + else: + continue + pool.pop(i) + changed = True + break + if cur[0] == cur[-1] and len(cur) >= 4: + rings.append(cur) + return rings + + +def clip_polyline(pts, xmin, ymin, xmax, ymax): + """Clip a polyline to a rectangle (Liang-Barsky per segment); returns list of pieces.""" + pieces, cur = [], [] + + def inside(p): + return xmin <= p[0] <= xmax and ymin <= p[1] <= ymax + + for i in range(len(pts) - 1): + (x1, y1), (x2, y2) = pts[i], pts[i + 1] + dx, dy = x2 - x1, y2 - y1 + t0, t1 = 0.0, 1.0 + ok = True + for p, q in ((-dx, x1 - xmin), (dx, xmax - x1), (-dy, y1 - ymin), (dy, ymax - y1)): + if p == 0: + if q < 0: + ok = False + break + else: + t = q / p + if p < 0: + if t > t1: + ok = False + break + t0 = max(t0, t) + else: + if t < t0: + ok = False + break + t1 = min(t1, t) + if not ok: + if cur: + pieces.append(cur) + cur = [] + continue + a = (x1 + t0 * dx, y1 + t0 * dy) + b = (x1 + t1 * dx, y1 + t1 * dy) + if not cur: + cur = [a, b] + else: + if cur[-1] != a: + pieces.append(cur) + cur = [a, b] + else: + cur.append(b) + if t1 < 1.0: + pieces.append(cur) + cur = [] + if cur: + pieces.append(cur) + return [dedupe(p) for p in pieces if len(dedupe(p)) >= 2] + + +def clip_polygon(ring, xmin, ymin, xmax, ymax): + """Sutherland-Hodgman clip of a ring to a rectangle.""" + def clip(poly, inside, inter): + out = [] + n = len(poly) + for i in range(n): + cur, prev = poly[i], poly[i - 1] + if inside(cur): + if not inside(prev): + out.append(inter(prev, cur)) + out.append(cur) + elif inside(prev): + out.append(inter(prev, cur)) + return out + + def ix(xc): + return lambda p, q: (xc, p[1] + (q[1] - p[1]) * (xc - p[0]) / (q[0] - p[0])) + + def iy(yc): + return lambda p, q: (p[0] + (q[0] - p[0]) * (yc - p[1]) / (q[1] - p[1]), yc) + + poly = list(ring) + for ins, inter in ((lambda p: p[0] >= xmin, ix(xmin)), (lambda p: p[0] <= xmax, ix(xmax)), + (lambda p: p[1] >= ymin, iy(ymin)), (lambda p: p[1] <= ymax, iy(ymax))): + if not poly: + break + poly = clip(poly, ins, inter) + return dedupe(poly) + + +def r1(v): + return round(v, 1) + + +def flat(pts): + out = [] + for x, y in pts: + out.append(r1(x)) + out.append(r1(y)) + return out + + +def write_json(path, obj): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + json.dump(obj, f, separators=(',', ':')) + print(f' wrote {os.path.relpath(path, ROOT)} {os.path.getsize(path)/1e6:.2f}MB') diff --git a/intro-sequence/tools/city2/build_scene.py b/intro-sequence/tools/city2/build_scene.py new file mode 100644 index 000000000..3d31e23c8 --- /dev/null +++ b/intro-sequence/tools/city2/build_scene.py @@ -0,0 +1,188 @@ +"""Build real-geodata scene packs. usage: python3 build_scene.py lax|bragg|everest [steps...] +steps: osm ground elev check (default all)""" +import json +import os +import sys + +import numpy as np +from PIL import Image, ImageDraw + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import common as C # noqa + +SCENES = { + "lax": dict(name="Los Angeles International Airport", lat0=33.9425, lon0=-118.4081, half=3500, z=17), + "bragg": dict(name="Fort Bragg, North Carolina", lat0=35.1580, lon0=-79.0090, half=6000, z=16), + "everest": dict(name="Mount Everest / Khumbu", lat0=27.9881, lon0=86.9250, half=15000, z=15), +} + +NT = {"lax": 4, "bragg": 6} +AERO_Q = '[out:json][timeout:600];nwr["aeroway"~"^(runway|taxiway|apron|terminal|gate|parking_position)$"]({bb});out geom;' +BLDG_Q = '[out:json][timeout:300];(way["building"]({bb});relation["building"]({bb}););out geom;' +ROAD_Q = ('[out:json][timeout:300];way["highway"~"^(motorway|trunk|primary|secondary|tertiary|' + 'residential|unclassified|living_street|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|service)$"]({bb});out geom;') +MIL_Q = '[out:json][timeout:600];(way["landuse"="military"]({bb});relation["landuse"="military"]({bb});way["military"]({bb});relation["military"]({bb}););out geom;' +PEAK_Q = '[out:json][timeout:300];node["natural"="peak"]({bb});out;' + + +def step_osm(f, slug): + bb = f.bbox_str() + if slug in ("lax", "bragg"): + aero = C.build_aeroway(f, C.overpass(AERO_Q.format(bb=bb), f"{slug}_aero")) + if slug == "lax": + C.wjson(os.path.join(f.out, "aeroway.json"), aero) + else: + mil = C.overpass(MIL_Q.format(bb=bb), f"{slug}_mil") + polys = [] + for el in mil["elements"]: + tags = el.get("tags", {}) + for ring in C.outer_rings(el, f): + name = tags.get("name") or (f"[{tags['military']}]" if "military" in tags else "") + polys.append([name] + C.flat(C.dp_ring(ring, 2.0))) + names = sorted({p[0] for p in polys if p[0]}) + print(f" military polygons: {len(polys)}; names: {names[:40]}") + out = {"military": polys, "runways": aero["runways"], "taxiways": aero["taxiways"], + "aprons": aero["aprons"], "apronNames": aero["apronNames"], "terminals": aero["terminals"], + "gates": aero["gates"], "parking": aero["parking"]} + C.wjson(os.path.join(f.out, "military.json"), out) + C.wjson(os.path.join(f.out, "buildings.json"), C.build_buildings(f, C.overpass_tiled(f, BLDG_Q, f"{slug}_bldg", NT[slug]))) + C.wjson(os.path.join(f.out, "roads.json"), C.build_roads(f, C.overpass_tiled(f, ROAD_Q, f"{slug}_roads", NT[slug] // 2))) + else: + d = C.overpass(PEAK_Q.format(bb=bb), f"{slug}_peaks") + peaks = [] + skipped = 0 + for el in d["elements"]: + t = el.get("tags", {}) + name = t.get("name:en") or t.get("name") + ele = t.get("ele") + if not name or not ele: + skipped += 1 + continue + try: + s = ele.lower().replace(",", ".").strip() + v = float(s.replace("ft", "").replace("m", "").strip().split()[0].split(";")[0]) + if "ft" in s: + v *= 0.3048 + except ValueError: + skipped += 1 + continue + x, y = f.xy(el["lon"], el["lat"]) + peaks.append([C.r1(x), C.r1(y), C.r1(v), name]) + peaks.sort(key=lambda p: -p[2]) + print(f" peaks with name+ele: {len(peaks)} (skipped {skipped} lacking name/ele); top: {peaks[:6]}") + C.wjson(os.path.join(f.out, "peaks.json"), peaks) + + +def step_elev(f): + z = 12 + url = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" + mos, ox, oy, nf = C.mosaic(f, z, url, "terrarium", "png", "RGB", pad_m=1500) + if nf: + print(f" WARNING: {nf} terrarium tiles missing") + m = mos.astype(np.float32) + h = m[..., 0] * 256 + m[..., 1] + m[..., 2] / 256 - 32768 + N = 1024 + e = C.reproject(f, h[..., None], ox, oy, z, N)[..., 0].astype("= 3: + dr.polygon(pts, outline=(0, 255, 255, 200)) + aero = json.load(open(os.path.join(f.out, "aeroway.json" if slug == "lax" else "military.json"))) + if slug == "bragg": + for m in aero["military"]: + dr.polygon(P(m[1:]), outline=(255, 0, 255, 255)) + for a in aero["aprons"]: + dr.polygon(P(a), outline=(255, 255, 255, 200)) + for t in aero["taxiways"]: + dr.line(P(t), fill=(255, 255, 0, 230), width=1) + for rw in aero["runways"]: + w = max(2, int(rw[0] / (2 * f.half) * N)) + dr.line(P(rw[2:6]), fill=(255, 40, 40, 110), width=w) + dr.line(P(rw[2:6]), fill=(255, 255, 255, 255), width=1) + for t in aero["terminals"]: + dr.polygon(P(t[1:]), outline=(255, 120, 0, 255)) + for g in aero["gates"]: + (x, y), = P(g[:2]) + dr.ellipse([x - 2, y - 2, x + 2, y + 2], fill=(0, 255, 0, 255)) + else: + peaks = json.load(open(os.path.join(f.out, "peaks.json"))) + for x0, y0, ele, name in peaks: + (x, y), = P([x0, y0]) + dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2) + if ele > 6000: + dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255)) + # contour-ish overlay from DEM to check imagery/DEM alignment + e = np.fromfile(os.path.join(f.out, "elev.f32"), " 0 + arr = np.asarray(im).copy() + arr[edge] = (0.4 * arr[edge] + 0.6 * np.array([0, 255, 255])).astype(np.uint8) + im = Image.fromarray(arr) + dr = ImageDraw.Draw(im, "RGBA") + for x0, y0, ele, name in peaks: + (x, y), = P([x0, y0]) + dr.ellipse([x - 4, y - 4, x + 4, y + 4], outline=(255, 60, 60, 255), width=2) + if ele > 6500: + dr.text((x + 6, y - 6), f"{name} {ele:.0f}", fill=(255, 255, 255, 255)) + p = os.path.join(f.out, "check.png") + im.save(p) + print(f" wrote {p}") + + +def main(): + slug = sys.argv[1] + steps = sys.argv[2:] or ["osm", "ground", "elev", "check"] + s = SCENES[slug] + f = C.Frame(slug, s["name"], s["lat0"], s["lon0"], s["half"]) + f.write_meta() + print(f"== {slug} bbox {f.bbox_str()}") + if "osm" in steps: + step_osm(f, slug) + if "ground" in steps: + C.build_ground(f, s["z"]) + if "ground_s2" in steps: + # alternative uniform real imagery (EOX Sentinel-2 cloudless 2020) — Esri is a patchwork here + C.build_ground(f, 14, C.EOX_S2, "eox_s2", "ground_s2") + if "elev" in steps and slug == "everest": + step_elev(f) + if "check" in steps: + step_check(f, slug) + + +if __name__ == "__main__": + main() diff --git a/intro-sequence/tools/city2/common.py b/intro-sequence/tools/city2/common.py new file mode 100644 index 000000000..f3fcec238 --- /dev/null +++ b/intro-sequence/tools/city2/common.py @@ -0,0 +1,571 @@ +"""Shared helpers for real-geodata scene builds (city2). + +Local frame: ENU metres relative to (lat0, lon0): + x = (lon - lon0) * cos(lat0) * 111320 + y = (lat - lat0) * 110540 +""" +import hashlib +import io +import json +import math +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import requests +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +CACHE = os.path.join(HERE, "cache") +CITY = os.path.abspath(os.path.join(HERE, "..", "..", "city")) +os.makedirs(CACHE, exist_ok=True) + +OVERPASS = [ + "https://overpass.kumi.systems/api/interpreter", + "https://overpass.private.coffee/api/interpreter", + "https://maps.mail.ru/osm/tools/overpass/api/interpreter", + "https://overpass-api.de/api/interpreter", +] +UA = {"User-Agent": "gods-eye-view-intro/1.0 (cinematic geodata build)"} + +Image.MAX_IMAGE_PIXELS = None + + +class Frame: + def __init__(self, slug, name, lat0, lon0, half): + self.slug, self.name, self.lat0, self.lon0, self.half = slug, name, lat0, lon0, half + self.kx = math.cos(math.radians(lat0)) * 111320.0 + self.ky = 110540.0 + self.out = os.path.join(CITY, slug) + os.makedirs(self.out, exist_ok=True) + + def xy(self, lon, lat): + return (lon - self.lon0) * self.kx, (lat - self.lat0) * self.ky + + def ll(self, x, y): + return self.lon0 + x / self.kx, self.lat0 + y / self.ky + + def bbox(self, pad=0.0): + """(south, west, north, east)""" + h = self.half + pad + return (self.lat0 - h / self.ky, self.lon0 - h / self.kx, + self.lat0 + h / self.ky, self.lon0 + h / self.kx) + + def bbox_str(self, pad=0.0): + s, w, n, e = self.bbox(pad) + return f"{s:.6f},{w:.6f},{n:.6f},{e:.6f}" + + def write_meta(self): + meta = {"slug": self.slug, "name": self.name, "lat0": self.lat0, "lon0": self.lon0, + "half": self.half, "frame": "ENU m"} + wjson(os.path.join(self.out, "meta.json"), meta) + + def geom_xy(self, geom): + """list of {lat,lon} -> Nx2 float array in local metres""" + a = np.array([[g["lon"], g["lat"]] for g in geom if g is not None], dtype=np.float64) + if len(a) == 0: + return np.zeros((0, 2)) + return np.stack([(a[:, 0] - self.lon0) * self.kx, (a[:, 1] - self.lat0) * self.ky], 1) + + +def wjson(path, obj): + with open(path, "w") as f: + json.dump(obj, f, separators=(",", ":")) + print(f" wrote {path} ({os.path.getsize(path)/1e6:.2f} MB)") + + +def r1(v): + return round(float(v), 1) + + +def flat(arr): + return [r1(v) for v in np.asarray(arr).reshape(-1)] + + +# ---------------------------------------------------------------- Overpass +def overpass(query, tag): + key = hashlib.sha1(query.encode()).hexdigest()[:12] + path = os.path.join(CACHE, f"overpass_{tag}_{key}.json") + if os.path.exists(path): + with open(path) as f: + return json.load(f) + last = None + for attempt in range(10): + for url in OVERPASS: + try: + print(f" overpass {tag} -> {url}", flush=True) + t = time.time() + r = requests.get(url, params={"data": query}, headers=UA, timeout=900) + if r.status_code != 200: + last = f"{url}: HTTP {r.status_code} {r.text[:200]}" + print(" ", last) + continue + d = r.json() + rem = d.get("remark", "") + if rem and ("runtime error" in rem or "timed out" in rem or "out of memory" in rem): + last = f"{url}: remark {rem[:200]}" + print(" ", last) + continue + print(f" {len(d.get('elements', []))} elements in {time.time()-t:.0f}s") + with open(path, "w") as f: + json.dump(d, f) + return d + except Exception as e: # noqa + last = f"{url}: {e}" + print(" ", last) + time.sleep(30) + raise RuntimeError(f"overpass failed for {tag}: {last}") + + +def overpass_tiled(frame, qfmt, tag, n): + """Run a query template ({bb} placeholder) over an n x n split of the frame bbox; dedupe elements.""" + s, w, nn, e = frame.bbox() + seen, els = set(), [] + for i in range(n): + for j in range(n): + bb = (f"{s + (nn - s) * i / n:.6f},{w + (e - w) * j / n:.6f}," + f"{s + (nn - s) * (i + 1) / n:.6f},{w + (e - w) * (j + 1) / n:.6f}") + d = overpass(qfmt.format(bb=bb), f"{tag}_{i}{j}") + for el in d["elements"]: + k = (el["type"], el["id"]) + if k not in seen: + seen.add(k) + els.append(el) + print(f" {tag}: {len(els)} unique elements over {n}x{n} tiles") + return {"elements": els} + + +# ---------------------------------------------------------------- geometry +def assemble_rings(ways): + """ways: list of Nx2 arrays (open or closed). Returns list of closed rings.""" + rings = [] + pending = [w for w in ways if len(w) >= 2] + while pending: + cur = pending.pop(0) + changed = True + while not np.allclose(cur[0], cur[-1], atol=1e-3) and changed: + changed = False + for i, w in enumerate(pending): + if np.allclose(cur[-1], w[0], atol=1e-3): + cur = np.vstack([cur, w[1:]]) + elif np.allclose(cur[-1], w[-1], atol=1e-3): + cur = np.vstack([cur, w[::-1][1:]]) + elif np.allclose(cur[0], w[-1], atol=1e-3): + cur = np.vstack([w, cur[1:]]) + elif np.allclose(cur[0], w[0], atol=1e-3): + cur = np.vstack([w[::-1], cur[1:]]) + else: + continue + pending.pop(i) + changed = True + break + if np.allclose(cur[0], cur[-1], atol=1e-3) and len(cur) >= 4: + rings.append(cur) + return rings + + +def outer_rings(el, frame): + """Outer rings (local metres, closed) for a way or multipolygon relation with out geom.""" + if el["type"] == "way": + g = el.get("geometry") + if not g: + return [] + a = frame.geom_xy(g) + if len(a) >= 4 and np.allclose(a[0], a[-1], atol=1e-3): + return [a] + return [] + if el["type"] == "relation": + ways = [frame.geom_xy(m["geometry"]) for m in el.get("members", []) + if m.get("type") == "way" and m.get("role", "outer") in ("outer", "") and m.get("geometry")] + return assemble_rings(ways) + return [] + + +def dp(pts, eps): + """Douglas-Peucker on Nx2 array (iterative).""" + n = len(pts) + if n < 3: + return pts + keep = np.zeros(n, bool) + keep[0] = keep[-1] = True + stack = [(0, n - 1)] + while stack: + a, b = stack.pop() + if b <= a + 1: + continue + p, q = pts[a], pts[b] + seg = pts[a + 1:b] + d = q - p + L = math.hypot(d[0], d[1]) + if L < 1e-9: + dist = np.hypot(seg[:, 0] - p[0], seg[:, 1] - p[1]) + else: + dist = np.abs(d[0] * (seg[:, 1] - p[1]) - d[1] * (seg[:, 0] - p[0])) / L + i = int(np.argmax(dist)) + if dist[i] > eps: + m = a + 1 + i + keep[m] = True + stack.append((a, m)) + stack.append((m, b)) + return pts[keep] + + +def dp_ring(ring, eps): + """DP for a closed ring: split at the vertex farthest from ring[0].""" + r = ring[:-1] if np.allclose(ring[0], ring[-1]) else ring + if len(r) < 4: + return r + far = int(np.argmax(np.hypot(*(r - r[0]).T))) + a = dp(np.vstack([r[:far + 1]]), eps) + b = dp(np.vstack([r[far:], r[:1]]), eps) + return np.vstack([a[:-1], b[:-1]]) # open ring (no repeated closing vertex) + + +def area(ring): + x, y = ring[:, 0], ring[:, 1] + return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) + + +def poly_len(a): + return float(np.sum(np.hypot(*np.diff(a, axis=0).T))) if len(a) > 1 else 0.0 + + +def clip_polyline(a, lim): + """Split a polyline into runs inside the square |x|,|y|<=lim, cutting segments at the edge.""" + out, cur = [], [] + inside = (np.abs(a[:, 0]) <= lim) & (np.abs(a[:, 1]) <= lim) + + def cut(p, q): + # point on segment p->q at the square boundary (p inside, q outside) + t = 1.0 + d = q - p + for k in range(2): + if d[k] > 0 and q[k] > lim: + t = min(t, (lim - p[k]) / d[k]) + elif d[k] < 0 and q[k] < -lim: + t = min(t, (-lim - p[k]) / d[k]) + return p + d * t + + for i in range(len(a)): + if inside[i]: + if not cur and i > 0: + cur.append(cut(a[i], a[i - 1])) + cur.append(a[i]) + else: + if cur: + cur.append(cut(a[i - 1], a[i])) + out.append(np.array(cur)) + cur = [] + if cur: + out.append(np.array(cur)) + return [c for c in out if len(c) >= 2] + + +def tag_height(tags, default=10.0): + def num(s): + try: + s = str(s).strip().lower().replace(",", ".") + ft = s.endswith("ft") or s.endswith("'") + s = s.replace("m", "").replace("ft", "").replace("'", "").strip().split(";")[0].split()[0] + v = float(s) + return v * 0.3048 if ft else v + except Exception: + return None + h = num(tags["height"]) if "height" in tags else None + if h is None and "building:levels" in tags: + lv = num(tags["building:levels"]) + if lv is not None: + h = lv * 3.2 + if h is None or h <= 0: + h = default + mh = num(tags["min_height"]) if "min_height" in tags else None + if mh is None and "building:min_level" in tags: + lv = num(tags["building:min_level"]) + mh = lv * 3.2 if lv is not None else None + mh = mh if (mh is not None and 0 <= mh < h) else 0.0 + return h, mh + + +# ---------------------------------------------------------------- buildings / roads +def build_buildings(frame, data, min_area=6.0): + b = [] + dropped = 0 + lim = frame.half + for el in data["elements"]: + if el["type"] not in ("way", "relation"): + continue + tags = el.get("tags", {}) + if "building" not in tags: + continue + h, mh = tag_height(tags) + for ring in outer_rings(el, frame): + c = ring[:-1].mean(0) + if abs(c[0]) > lim or abs(c[1]) > lim: + continue + s = dp_ring(ring, 0.5) + if len(s) < 3 or area(s) < min_area: + dropped += 1 + continue + b.append([r1(h), r1(mh)] + flat(s)) + print(f" buildings: {len(b)} kept, {dropped} tiny dropped") + return {"count": len(b), "b": b} + + +ROAD_CLASS = { + "motorway": 0, "trunk": 0, + "primary": 1, "secondary": 2, "tertiary": 3, + "residential": 4, "unclassified": 4, "living_street": 4, + "motorway_link": 5, "trunk_link": 5, "primary_link": 5, "secondary_link": 5, + "tertiary_link": 5, "service": 5, +} +DEFAULT_LANES = {0: 3, 1: 2, 2: 2, 3: 2, 4: 1, 5: 1} + + +def build_roads(frame, data): + roads = [] + counts = {} + lim = frame.half + 50 + for el in data["elements"]: + if el["type"] != "way" or not el.get("geometry"): + continue + tags = el.get("tags", {}) + hw = tags.get("highway") + if hw not in ROAD_CLASS: + continue + cls = ROAD_CLASS[hw] + a = frame.geom_xy(el["geometry"]) + if hw == "service" and poly_len(a) <= 80: + continue + ow = tags.get("oneway", "") + oneway = 0 + if ow in ("yes", "1", "true") or (hw in ("motorway",) and ow != "no") or tags.get("junction") in ("roundabout", "circular"): + oneway = 1 + elif ow == "-1": + oneway = 1 + a = a[::-1] + try: + lanes = int(str(tags.get("lanes", "")).split(";")[0]) + except ValueError: + lanes = DEFAULT_LANES[cls] * (1 if oneway else 2) if cls < 4 else (1 if oneway else 2) + for part in clip_polyline(a, lim): + if poly_len(part) < 2: + continue + roads.append([cls, oneway, lanes] + flat(part)) + counts[cls] = counts.get(cls, 0) + 1 + print(f" roads: {len(roads)} polylines, by class {dict(sorted(counts.items()))}") + return {"roads": roads} + + +# ---------------------------------------------------------------- aeroway +def build_aeroway(frame, data): + lim = frame.half + 200 + runways, taxiways, aprons, terminals, gates, parking = [], [], [], [], [], [] + apron_names = [] + for el in data["elements"]: + tags = el.get("tags", {}) + aw = tags.get("aeroway") + if el["type"] == "node": + x, y = frame.xy(el["lon"], el["lat"]) + if abs(x) > lim or abs(y) > lim: + continue + if aw == "gate": + gates.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))]) + elif aw == "parking_position": + parking.append([r1(x), r1(y), tags.get("ref", tags.get("name", ""))]) + continue + if aw == "runway" and el["type"] == "way" and el.get("geometry"): + a = frame.geom_xy(el["geometry"]) + closed = len(a) > 3 and np.allclose(a[0], a[-1], atol=1e-3) + try: + w = float(str(tags.get("width", "")).replace("m", "").strip()) + except ValueError: + w = None + if closed or tags.get("area") == "yes": + # area runway: use its long axis (PCA) as centreline, width = short extent + c = a.mean(0) + u, s, vt = np.linalg.svd(a - c, full_matrices=False) + ax = vt[0] + t = (a - c) @ ax + n = (a - c) @ vt[1] + p1, p2 = c + ax * t.min(), c + ax * t.max() + w = w or float(n.max() - n.min()) + else: + p1, p2 = a[0], a[-1] + runways.append([r1(w or 45.0), tags.get("ref", ""), r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])]) + elif aw == "taxiway" and el["type"] == "way" and el.get("geometry"): + a = frame.geom_xy(el["geometry"]) + if tags.get("area") == "yes": + continue + for part in clip_polyline(a, lim): + taxiways.append(flat(part)) + elif aw == "apron": + for ring in outer_rings(el, frame): + c = ring.mean(0) + if abs(c[0]) > lim or abs(c[1]) > lim: + continue + aprons.append(flat(dp_ring(ring, 0.5))) + apron_names.append(tags.get("name", "")) + elif aw == "terminal": + h, _ = tag_height(tags) + for ring in outer_rings(el, frame): + c = ring.mean(0) + if abs(c[0]) > lim or abs(c[1]) > lim: + continue + terminals.append([r1(h)] + flat(dp_ring(ring, 0.5))) + # OSM often splits one runway into several ways (displaced thresholds, blast pads): + # merge segments sharing a ref into one centreline spanning their extreme endpoints. + merged, groups = [], {} + for rw in runways: + if rw[1]: + groups.setdefault(rw[1], []).append(rw) + else: + merged.append(rw) + for ref, segs in groups.items(): + pts = np.array([[s[2], s[3]] for s in segs] + [[s[4], s[5]] for s in segs]) + main = max(segs, key=lambda s: math.hypot(s[4] - s[2], s[5] - s[3])) + ax = np.array([main[4] - main[2], main[5] - main[3]]) + ax /= np.linalg.norm(ax) + t = (pts - pts[0]) @ ax + p1, p2 = pts[int(np.argmin(t))], pts[int(np.argmax(t))] + merged.append([main[0], ref, r1(p1[0]), r1(p1[1]), r1(p2[0]), r1(p2[1])]) + if len(merged) != len(runways): + print(f" runways: merged {len(runways)} OSM segments into {len(merged)}") + runways = merged + out = {"runways": runways, "taxiways": taxiways, "aprons": aprons, "apronNames": apron_names, + "terminals": terminals, "gates": gates, "parking": parking} + print(" aeroway: " + ", ".join(f"{k}={len(v)}" for k, v in out.items())) + return out + + +# ---------------------------------------------------------------- tiles / imagery +def merc_px(lon, lat, z, ts=256): + n = 2 ** z + x = (lon + 180.0) / 360.0 * n * ts + lat_r = np.radians(lat) + y = (1.0 - np.log(np.tan(lat_r) + 1.0 / np.cos(lat_r)) / math.pi) / 2.0 * n * ts + return x, y + + +def fetch_tiles(url_fmt, tiles, subdir, ext, workers=8): + d = os.path.join(CACHE, subdir) + os.makedirs(d, exist_ok=True) + sess = requests.Session() + sess.headers.update(UA) + fails = [] + + def get(t): + z, x, y = t + p = os.path.join(d, f"{z}_{x}_{y}.{ext}") + if os.path.exists(p) and os.path.getsize(p) > 0: + return p + for i in range(4): + try: + r = sess.get(url_fmt.format(z=z, x=x, y=y), timeout=60) + if r.status_code == 200 and len(r.content) > 100: + with open(p + ".tmp", "wb") as f: + f.write(r.content) + os.replace(p + ".tmp", p) + return p + if r.status_code == 404: + break + except Exception: + pass + time.sleep(1.5 * (i + 1)) + fails.append(t) + return None + + with ThreadPoolExecutor(workers) as ex: + res = list(ex.map(get, tiles)) + return dict(zip(tiles, res)), fails + + +def mosaic(frame, z, url_fmt, subdir, ext, mode, pad_m=300.0): + s, w, n, e = frame.bbox(pad_m) + x0, y1 = merc_px(w, s, z) + x1, y0 = merc_px(e, n, z) + tx0, tx1 = int(x0 // 256), int(x1 // 256) + ty0, ty1 = int(y0 // 256), int(y1 // 256) + tiles = [(z, x, y) for y in range(ty0, ty1 + 1) for x in range(tx0, tx1 + 1)] + print(f" {subdir} z{z}: {tx1-tx0+1}x{ty1-ty0+1} = {len(tiles)} tiles", flush=True) + paths, fails = fetch_tiles(url_fmt, tiles, subdir, ext) + if fails: + print(f" WARNING: {len(fails)} tiles failed: {fails[:10]}") + W, H = (tx1 - tx0 + 1) * 256, (ty1 - ty0 + 1) * 256 + nb = 3 if mode == "RGB" else 3 + mos = np.zeros((H, W, nb), np.uint8) + for (zz, x, y), p in paths.items(): + if p is None: + continue + im = Image.open(p).convert("RGB") + if im.size != (256, 256): + im = im.resize((256, 256)) + mos[(y - ty0) * 256:(y - ty0 + 1) * 256, (x - tx0) * 256:(x - tx0 + 1) * 256] = np.asarray(im) + return mos, tx0 * 256, ty0 * 256, len(fails) + + +def bilinear(img, px, py): + """img HxWxC (float or uint8), px/py float arrays in pixel coords (pixel centres at i+0.5).""" + H, W = img.shape[:2] + fx = np.clip(px - 0.5, 0, W - 1.000001) + fy = np.clip(py - 0.5, 0, H - 1.000001) + x0 = np.floor(fx).astype(np.int64) + y0 = np.floor(fy).astype(np.int64) + ax = (fx - x0)[..., None] + ay = (fy - y0)[..., None] + im = img if img.ndim == 3 else img[..., None] + a = im[y0, x0].astype(np.float32) + b = im[y0, x0 + 1].astype(np.float32) + c = im[y0 + 1, x0].astype(np.float32) + d = im[y0 + 1, x0 + 1].astype(np.float32) + out = (a * (1 - ax) + b * ax) * (1 - ay) + (c * (1 - ax) + d * ax) * ay + return out if img.ndim == 3 else out[..., 0] + + +def reproject(frame, img, ox, oy, z, N, rows_per=256): + """Sample a Mercator mosaic (origin global px ox,oy at zoom z) onto the local NxN square.""" + h = frame.half + out = np.zeros((N, N) + img.shape[2:], np.float32) + xs = -h + (np.arange(N) + 0.5) * (2 * h / N) + for r0 in range(0, N, rows_per): + r = np.arange(r0, min(N, r0 + rows_per)) + ys = h - (r + 0.5) * (2 * h / N) + X, Y = np.meshgrid(xs, ys) + lon, lat = frame.ll(X, Y) + mx, my = merc_px(lon, lat, z) + out[r0:r0 + len(r)] = bilinear(img, mx - ox, my - oy) + return out + + +ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" + + +EOX_S2 = "https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2020_3857/default/g/{z}/{y}/{x}.jpg" + + +def build_ground(frame, z, url=ESRI, sub="esri", name="ground"): + mos, ox, oy, nf = mosaic(frame, z, url, sub, "jpg", "RGB") + out = reproject(frame, mos, ox, oy, z, 4096) + im = Image.fromarray(np.clip(out + 0.5, 0, 255).astype(np.uint8)) + p = os.path.join(frame.out, f"{name}.jpg") + im.save(p, quality=90, optimize=True) + print(f" wrote {p} ({os.path.getsize(p)/1e6:.2f} MB)") + im2 = im.resize((2048, 2048), Image.LANCZOS) + p2 = os.path.join(frame.out, f"{name}_2k.jpg") + im2.save(p2, quality=88, optimize=True) + print(f" wrote {p2} ({os.path.getsize(p2)/1e6:.2f} MB)") + return nf + + +# ---------------------------------------------------------------- check overlay +def to_px(frame, a, N): + a = np.asarray(a, dtype=np.float64).reshape(-1, 2) + return [((x + frame.half) / (2 * frame.half) * N, (frame.half - y) / (2 * frame.half) * N) for x, y in a] + + +def check_canvas(frame, dark=0.45): + from PIL import ImageEnhance + im = Image.open(os.path.join(frame.out, "ground_2k.jpg")).convert("RGB") + im = ImageEnhance.Brightness(im).enhance(dark) + return im diff --git a/intro-sequence/tools/export-cues.mjs b/intro-sequence/tools/export-cues.mjs index 5e951245d..6dfb8bb00 100644 --- a/intro-sequence/tools/export-cues.mjs +++ b/intro-sequence/tools/export-cues.mjs @@ -1,6 +1,6 @@ // Export the film clock for the score: every visual event the audio locks to. import fs from 'node:fs'; -import { T, FEEDS, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js'; +import { T, FEEDS, CITIES, DURATION, captureTimes, CAPTURE_SLOW } from '../src/timeline.js'; import { angDist } from '../src/geo.js'; const heroes = JSON.parse(fs.readFileSync('imagery/hero/index.json', 'utf8')); @@ -34,7 +34,7 @@ const boot = []; } }); -const status = [53.4, 54.5, 55.6, 56.7, 57.7]; +const status = [77.4, 78.5, 79.6, 80.7, 81.7]; const rapid = Array.from({ length: 7 }, (_, i) => T.rapid + i * 0.22); -fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), caps, tiles, boot, status, rapid }, null, 1)); +fs.writeFileSync('audio/cues.json', JSON.stringify({ T, DURATION, feeds: FEEDS.map((x) => ({ key: x.key, t: x.t })), cities: CITIES.map((c) => ({ slug: c.slug, t0: c.t0, t1: c.t1 })), caps, tiles, boot, status, rapid }, null, 1)); console.log('cues:', caps.length, 'captures,', tiles.length, 'tiles,', boot.length, 'keystrokes'); diff --git a/intro-sequence/tools/finalize-v2.sh b/intro-sequence/tools/finalize-v2.sh new file mode 100755 index 000000000..a61621592 --- /dev/null +++ b/intro-sequence/tools/finalize-v2.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# v2: sound design only (made to sit under a music track). Normalise the SFX +# mix to -16 LUFS, keep it as a stem, and mux share + master cuts. +set -euo pipefail +cd "$(dirname "$0")/.." +node tools/export-cues.mjs +python3 audio/sfx.py +stats=$(ffmpeg -hide_banner -i audio/sfx.wav -af loudnorm=I=-16:TP=-1.5:LRA=14:print_format=json -f null - 2>&1 | sed -n '/^{/,/^}/p') +g() { echo "$stats" | python3 -c "import json,sys;print(json.load(sys.stdin)['$1'])"; } +ffmpeg -y -loglevel error -i audio/sfx.wav -af "loudnorm=I=-16:TP=-1.5:LRA=14:measured_I=$(g input_i):measured_TP=$(g input_tp):measured_LRA=$(g input_lra):measured_thresh=$(g input_thresh):linear=true,aresample=48000" -c:a pcm_s24le audio/sfx_norm.wav +mkdir -p out/final_v2 +cp audio/sfx_norm.wav out/final_v2/GodsEyeView_FirstLight_v2_SFX_stem.wav +for tag in 16x9 9x16; do + v=out/v2_video_$tag.mp4 + [ -s "$v" ] || { echo "missing $v"; continue; } + ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 320k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}_master.mp4 + ffmpeg -y -loglevel error -i $v -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 1 -passlogfile out/final_v2/p_$tag -an -f null /dev/null + ffmpeg -y -loglevel error -i $v -i audio/sfx_norm.wav -map 0:v -map 1:a -c:v libx264 -preset slow -b:v 16M -maxrate 24M -bufsize 32M -pass 2 -passlogfile out/final_v2/p_$tag \ + -pix_fmt yuv420p -colorspace bt709 -color_primaries bt709 -color_trc bt709 -c:a aac -b:a 256k -shortest -movflags +faststart out/final_v2/GodsEyeView_FirstLight_v2_${tag}.mp4 + rm -f out/final_v2/p_$tag* + echo "finalized $tag" +done +ls -la out/final_v2 diff --git a/intro-sequence/tools/flurry/build.py b/intro-sequence/tools/flurry/build.py new file mode 100644 index 000000000..f315e5ce9 --- /dev/null +++ b/intro-sequence/tools/flurry/build.py @@ -0,0 +1,175 @@ +"""Build the flurry montage captures. + + python3 select_cities.py # -> city_candidates.json (primary + backup per region) + python3 build.py # -> imagery/flurry/fx_###.jpg, index.json, tools/flurry/sheet.jpg + +Each capture: Esri World Imagery, 3x3 tiles around the point, centred 512x512 +crop, JPEG q86. Placeholder / low-variance captures are rejected automatically +(tiles.quality_ok); captures that passed the metrics but looked wrong on the +review sheets (forest, farmland, haze) are listed in VISUAL_REJECT. Rejected +items are replaced by the next backup of the same region (cities) or by the +next backup landmark. +""" +import glob +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor + +from PIL import Image + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from landmarks import LANDMARKS # noqa: E402 +from tiles import HERE, MAX_WORKERS, OUT, capture, quality_ok # noqa: E402 + +N_CITIES = 170 +N_LANDMARKS = 90 + +# Natural Earth points that landed on a park/water/cemetery: moved to the dense centre. +CITY_OVERRIDES = { + "Lagos": (6.4541, 3.3947), # Lagos Island (was open lagoon) + "Cairns": (-16.9230, 145.7740), # CBD (was mangrove mudflat) + "Calgary": (51.0460, -114.0650), # downtown (was cemetery) + "Dubai": (25.0805, 55.1403), # Dubai Marina (was construction lot) + "Perth": (-31.9530, 115.8575), # CBD (was Kings Park bush) + "Dhaka": (23.7330, 90.4172), # Motijheel (was hazy open ground) + "Delhi": (28.6315, 77.2167), # Connaught Place + "Montréal": (45.5017, -73.5673), # downtown (was Mount Royal park) + "Busan": (35.1000, 129.0350), # Nampo / harbour (was forested hill) + "Jeddah": (21.4858, 39.1925), # Al-Balad old town (was empty lot) + "Tokyo": (35.6717, 139.7650), # Ginza (was Imperial Palace woods) +} +VISUAL_REJECT = { + "Venice": "blurry low-res imagery", + "Conakry": "heavy haze", + "Maracaibo": "farmland / scrub", + "Barquisimeto": "dry riverbed, no city", + "Natal": "solid forest canopy", + "João Pessoa": "forest canopy", + "Mbuji-Mayi": "forest/scrub", + "Huambo": "scrub, sparse", + "Port Harcourt": "haze", + "Qaraghandy": "dark featureless rail yard", + "Herat": "low-res blur", + "Chengdu": "smog haze", + "Xian": "washed-out industrial lot", + "Kaohsiung": "forested hill", + "Sapporo": "university farm fields", + "Isfahan": "low contrast blur", + "Stuttgart": "hazy woodland", + "Townsville": "airport apron, not city", +} +LANDMARK_BACKUPS = [ + "Glen Canyon Dam", "Grand Coulee Dam", "Mount St. Helens", "Groom Lake", "Diavik Diamond Mine", + "Syncrude Mildred Lake oil sands", "Centre-pivot fields, Kansas", "Indianapolis Motor Speedway", + "Brooklyn Bridge", "Panama Canal, Miraflores Locks", "Escondida Mine", "ALMA Observatory", + "Meeting of the Waters, Manaus", "Brasilia, National Congress", "PS10 / PS20 Solar Towers", "Tower Bridge", + "Port of Tema", "Akosombo Dam", "Okavango Delta", "FNB Stadium, Soweto", "East Oweinat pivots", + "Kilimanjaro, Kibo crater", "Ras Laffan Industrial City", "Dubai International Airport", "Bagan temples", + "Jiuquan Launch Centre", "Mount Bromo", "Vostochny Cosmodrome", "Pine Gap", "Tanegashima Space Center", + "Teide", "Faa'a International Airport", "Hartsfield-Jackson Atlanta Airport", "Niagara Horseshoe Falls", + "Iguazu Falls, Devil's Throat", "Vandenberg SLC-4E", "Paranal Observatory", "Cerro Dominador Solar", + "Gibraltar Airport runway", "Erg Chebbi dunes", "Millau Viaduct", "Garzweiler Lignite Mine", "Inga Dams", + "Grand Ethiopian Renaissance Dam", "Ras Tanura refinery", "Udachnaya Pipe", "Busan New Port", + "Mount Taranaki", "Beijing National Stadium", "Port of Hamburg, Burchardkai", "Port of Santos", + "Jebel Ali Port", "Mont-Saint-Michel", "Angkor Wat", "Mount Vesuvius", "Horseshoe Bend", + "Benban Solar Park", "Borobudur", "Bhadla Solar Park", "Kariba Dam", "Wake Island Airfield", + "Diamond Head", "Port Hedland salt ponds", +] + + +def evaluate(cands): + def job(c): + try: + img, info = capture(c["lat"], c["lon"], c["zoom"]) + ok, why = quality_ok(info) + except Exception as e: # network failure counts as a reject + img, info, ok, why = None, {}, False, f"fetch error: {e}" + if ok and c["name"] in VISUAL_REJECT and c.get("kind") == "city": + ok, why = False, "visual: " + VISUAL_REJECT[c["name"]] + return img, ok, why + + with ThreadPoolExecutor(MAX_WORKERS) as ex: + return list(ex.map(job, cands)) + + +def pick(cands, results, quota_by_group): + """Primaries first in order; failures replaced by the group's backups in order.""" + chosen, rejects = [], [] + for g, quota in quota_by_group.items(): + items = [(c, r) for c, r in zip(cands, results) if c["group"] == g] + prim = [x for x in items if x[0]["rank"] == "primary"] + back = [x for x in items if x[0]["rank"] == "backup"] + got = [] + for c, r in prim + back: + if len(got) >= quota: + break + if r[1]: + got.append((c, r[0])) + elif c["rank"] == "primary": + rejects.append((c, r[2])) + if len(got) < quota: + print(f"!! group {g}: only {len(got)}/{quota}") + chosen += got + return chosen, rejects + + +def main(): + cities = json.load(open(os.path.join(HERE, "city_candidates.json"))) + for c in cities: + c["kind"], c["group"] = "city", "c:" + c["region"] + if c["name"] in CITY_OVERRIDES: + c["lat"], c["lon"] = CITY_OVERRIDES[c["name"]] + cquota = {} + for c in cities: + if c["rank"] == "primary": + cquota[c["group"]] = cquota.get(c["group"], 0) + 1 + assert sum(cquota.values()) == N_CITIES, cquota + + lms = [dict(name=n, country=co, lat=la, lon=lo, zoom=z, kind="landmark", group="landmark", + rank="backup" if n in LANDMARK_BACKUPS else "primary") for n, co, la, lo, z in LANDMARKS] + lms.sort(key=lambda c: c["rank"] != "primary") # stable: primaries first + nprim = sum(c["rank"] == "primary" for c in lms) + assert nprim == N_LANDMARKS, nprim + + allc = cities + lms + print(f"evaluating {len(allc)} candidates ...", flush=True) + res = evaluate(allc) + quotas = dict(cquota) + quotas["landmark"] = N_LANDMARKS + chosen, rejects = pick(allc, res, quotas) + + chosen.sort(key=lambda x: x[0]["lon"]) + os.makedirs(OUT, exist_ok=True) + for f in glob.glob(os.path.join(OUT, "fx_*.jpg")): + os.remove(f) + index = [] + for i, (c, img) in enumerate(chosen): + fn = f"fx_{i:03d}.jpg" + img.save(os.path.join(OUT, fn), quality=86, optimize=True) + index.append({"file": fn, "name": c["name"], "country": c["country"], "lat": round(c["lat"], 5), + "lon": round(c["lon"], 5), "zoom": c["zoom"], "kind": c["kind"]}) + with open(os.path.join(OUT, "index.json"), "w") as f: + json.dump(index, f, indent=1, ensure_ascii=False) + + # contact sheet: 16 x 17 grid of 96px thumbs, west->east reading order + cols, cell = 16, 96 + rows = (len(chosen) + cols - 1) // cols + sheet = Image.new("RGB", (cols * cell, rows * cell), (10, 10, 10)) + for i, (c, img) in enumerate(chosen): + sheet.paste(img.resize((cell, cell), Image.LANCZOS), ((i % cols) * cell, (i // cols) * cell)) + sheet.save(os.path.join(HERE, "sheet.jpg"), quality=88) + + from collections import Counter + print(f"wrote {len(index)} captures: {Counter(x['kind'] for x in index)}") + print("rejected primaries (replaced by backups):") + for c, why in rejects: + print(f" - {c['name']} ({c['country']}): {why}") + bands = Counter(int((x["lon"] + 180) // 30) for x in index) + print("30-degree longitude bands W->E:", [bands.get(i, 0) for i in range(12)]) + json.dump([{"name": c["name"], "country": c["country"], "reason": why} for c, why in rejects], + open(os.path.join(HERE, "rejects.json"), "w"), indent=1, ensure_ascii=False) + + +if __name__ == "__main__": + main() diff --git a/intro-sequence/tools/flurry/city_candidates.json b/intro-sequence/tools/flurry/city_candidates.json new file mode 100644 index 000000000..14ee1f2ba --- /dev/null +++ b/intro-sequence/tools/flurry/city_candidates.json @@ -0,0 +1,2496 @@ +[ +{ +"name": "Honolulu", +"country": "United States of America", +"lat": 21.303, +"lon": -157.858, +"zoom": 16, +"pop": 786000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Anchorage", +"country": "United States of America", +"lat": 61.214, +"lon": -149.887, +"zoom": 16, +"pop": 260283, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Papeete", +"country": "French Polynesia", +"lat": -17.533, +"lon": -149.567, +"zoom": 16, +"pop": 131695, +"region": "oceania", +"rank": "primary", +"forced": true +}, +{ +"name": "Agana", +"country": "Guam", +"lat": 13.47, +"lon": 144.75, +"zoom": 16, +"pop": 122411, +"region": "oceania", +"rank": "primary", +"forced": true +}, +{ +"name": "Petropavlovsk-Kamchatsky", +"country": "Russia", +"lat": 53.062, +"lon": 158.623, +"zoom": 16, +"pop": 187282, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Vancouver", +"country": "Canada", +"lat": 49.275, +"lon": -123.124, +"zoom": 16, +"pop": 2313328, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "San Francisco", +"country": "United States of America", +"lat": 37.784, +"lon": -122.4, +"zoom": 16, +"pop": 3450000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Las Vegas", +"country": "United States of America", +"lat": 36.165, +"lon": -115.151, +"zoom": 16, +"pop": 1823000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Seattle", +"country": "United States of America", +"lat": 47.6, +"lon": -122.315, +"zoom": 16, +"pop": 3074000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Phoenix", +"country": "United States of America", +"lat": 33.448, +"lon": -112.067, +"zoom": 16, +"pop": 3551000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Denver", +"country": "United States of America", +"lat": 39.741, +"lon": -104.986, +"zoom": 16, +"pop": 2313000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Calgary", +"country": "Canada", +"lat": 51.085, +"lon": -114.082, +"zoom": 16, +"pop": 1110000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Guatemala City", +"country": "Guatemala", +"lat": 14.623, +"lon": -90.529, +"zoom": 16, +"pop": 1024000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Havana", +"country": "Cuba", +"lat": 23.134, +"lon": -82.366, +"zoom": 16, +"pop": 2174000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Reykjavík", +"country": "Iceland", +"lat": 64.143, +"lon": -21.937, +"zoom": 16, +"pop": 166212, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Dubai", +"country": "United Arab Emirates", +"lat": 25.215, +"lon": 55.287, +"zoom": 16, +"pop": 1379000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Doha", +"country": "Qatar", +"lat": 25.287, +"lon": 51.533, +"zoom": 16, +"pop": 1450000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Abu Dhabi", +"country": "United Arab Emirates", +"lat": 24.467, +"lon": 54.367, +"zoom": 16, +"pop": 603492, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Hong Kong", +"country": "Hong Kong S.A.R.", +"lat": 22.307, +"lon": 114.183, +"zoom": 16, +"pop": 7206000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Macau", +"country": "Macau S.A.R", +"lat": 22.189, +"lon": 113.544, +"zoom": 16, +"pop": 568700, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Venice", +"country": "Italy", +"lat": 45.439, +"lon": 12.335, +"zoom": 16, +"pop": 270816, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Amsterdam", +"country": "Netherlands", +"lat": 52.352, +"lon": 4.915, +"zoom": 16, +"pop": 1031000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "København", +"country": "Denmark", +"lat": 55.681, +"lon": 12.562, +"zoom": 16, +"pop": 1085000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Oslo", +"country": "Norway", +"lat": 59.919, +"lon": 10.748, +"zoom": 16, +"pop": 835000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Stockholm", +"country": "Sweden", +"lat": 59.324, +"lon": 18.066, +"zoom": 16, +"pop": 1264000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Helsinki", +"country": "Finland", +"lat": 60.164, +"lon": 24.932, +"zoom": 16, +"pop": 1115000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Prague", +"country": "Czechia", +"lat": 50.087, +"lon": 14.423, +"zoom": 16, +"pop": 1162000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Kathmandu", +"country": "Nepal", +"lat": 27.719, +"lon": 85.315, +"zoom": 16, +"pop": 895000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Colombo", +"country": "Sri Lanka", +"lat": 6.932, +"lon": 79.858, +"zoom": 16, +"pop": 217000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Vladivostok", +"country": "Russia", +"lat": 43.13, +"lon": 131.91, +"zoom": 16, +"pop": 587022, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Novosibirsk", +"country": "Russia", +"lat": 55.032, +"lon": 82.958, +"zoom": 16, +"pop": 1389000, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Irkutsk", +"country": "Russia", +"lat": 52.285, +"lon": 104.284, +"zoom": 16, +"pop": 586695, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Yakutsk", +"country": "Russia", +"lat": 62.035, +"lon": 129.735, +"zoom": 16, +"pop": 235600, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Norilsk", +"country": "Russia", +"lat": 69.34, +"lon": 88.225, +"zoom": 16, +"pop": 165873, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Murmansk", +"country": "Russia", +"lat": 68.97, +"lon": 33.1, +"zoom": 16, +"pop": 319263, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Manaus", +"country": "Brazil", +"lat": -3.098, +"lon": -60.002, +"zoom": 16, +"pop": 1753000, +"region": "south_america", +"rank": "primary", +"forced": true +}, +{ +"name": "Mombasa", +"country": "Kenya", +"lat": -4.038, +"lon": 39.688, +"zoom": 16, +"pop": 882000, +"region": "africa", +"rank": "primary", +"forced": true +}, +{ +"name": "Windhoek", +"country": "Namibia", +"lat": -22.57, +"lon": 17.084, +"zoom": 16, +"pop": 268132, +"region": "africa", +"rank": "primary", +"forced": true +}, +{ +"name": "Nouakchott", +"country": "Mauritania", +"lat": 18.086, +"lon": -15.975, +"zoom": 16, +"pop": 742144, +"region": "africa", +"rank": "primary", +"forced": true +}, +{ +"name": "Marrakesh", +"country": "Morocco", +"lat": 31.632, +"lon": -8.002, +"zoom": 16, +"pop": 872000, +"region": "africa", +"rank": "primary", +"forced": true +}, +{ +"name": "Samarkand", +"country": "Uzbekistan", +"lat": 39.67, +"lon": 66.945, +"zoom": 16, +"pop": 708000, +"region": "central_asia", +"rank": "primary", +"forced": true +}, +{ +"name": "Cairns", +"country": "Australia", +"lat": -16.888, +"lon": 145.763, +"zoom": 16, +"pop": 154225, +"region": "oceania", +"rank": "primary", +"forced": true +}, +{ +"name": "Muscat", +"country": "Oman", +"lat": 23.585, +"lon": 58.378, +"zoom": 16, +"pop": 734697, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Québec", +"country": "Canada", +"lat": 46.84, +"lon": -71.246, +"zoom": 16, +"pop": 624177, +"region": "rest", +"rank": "primary", +"forced": true +}, +{ +"name": "Cairo", +"country": "Egypt", +"lat": 30.052, +"lon": 31.248, +"zoom": 16, +"pop": 11893000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Lagos", +"country": "Nigeria", +"lat": 6.445, +"lon": 3.39, +"zoom": 16, +"pop": 9466000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Kinshasa", +"country": "Congo (Kinshasa)", +"lat": -4.328, +"lon": 15.313, +"zoom": 16, +"pop": 7843000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Luanda", +"country": "Angola", +"lat": -8.836, +"lon": 13.232, +"zoom": 16, +"pop": 5172900, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Khartoum", +"country": "Sudan", +"lat": 15.59, +"lon": 32.532, +"zoom": 16, +"pop": 4754000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Abidjan", +"country": "Ivory Coast", +"lat": 5.323, +"lon": -4.02, +"zoom": 16, +"pop": 3802000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Johannesburg", +"country": "South Africa", +"lat": -26.168, +"lon": 28.028, +"zoom": 16, +"pop": 3435000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Algiers", +"country": "Algeria", +"lat": 36.765, +"lon": 3.049, +"zoom": 16, +"pop": 3354000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Cape Town", +"country": "South Africa", +"lat": -33.918, +"lon": 18.433, +"zoom": 16, +"pop": 3215000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Kano", +"country": "Nigeria", +"lat": 12.002, +"lon": 8.518, +"zoom": 16, +"pop": 3140000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Addis Ababa", +"country": "Ethiopia", +"lat": 9.035, +"lon": 38.698, +"zoom": 16, +"pop": 3100000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Nairobi", +"country": "Kenya", +"lat": -1.281, +"lon": 36.815, +"zoom": 16, +"pop": 3010000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Dar es Salaam", +"country": "Tanzania", +"lat": -6.798, +"lon": 39.266, +"zoom": 16, +"pop": 2930000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Durban", +"country": "South Africa", +"lat": -29.861, +"lon": 31.011, +"zoom": 16, +"pop": 2729000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Dakar", +"country": "Senegal", +"lat": 14.718, +"lon": -17.475, +"zoom": 16, +"pop": 2604000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Tunis", +"country": "Tunisia", +"lat": 36.803, +"lon": 10.18, +"zoom": 16, +"pop": 2412500, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Tripoli", +"country": "Libya", +"lat": 32.892, +"lon": 13.18, +"zoom": 16, +"pop": 2189000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Accra", +"country": "Ghana", +"lat": 5.552, +"lon": -0.219, +"zoom": 16, +"pop": 2121000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Douala", +"country": "Cameroon", +"lat": 4.062, +"lon": 9.708, +"zoom": 16, +"pop": 1906000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Antananarivo", +"country": "Madagascar", +"lat": -18.915, +"lon": 47.515, +"zoom": 16, +"pop": 1697000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Abuja", +"country": "Nigeria", +"lat": 9.055, +"lon": 7.49, +"zoom": 16, +"pop": 1576000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Harare", +"country": "Zimbabwe", +"lat": -17.816, +"lon": 31.043, +"zoom": 16, +"pop": 1572000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Bamako", +"country": "Mali", +"lat": 12.652, +"lon": -8.002, +"zoom": 16, +"pop": 1494000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Conakry", +"country": "Guinea", +"lat": 9.533, +"lon": -13.682, +"zoom": 16, +"pop": 1494000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Maputo", +"country": "Mozambique", +"lat": -25.953, +"lon": 32.587, +"zoom": 16, +"pop": 1446000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Kampala", +"country": "Uganda", +"lat": 0.319, +"lon": 32.581, +"zoom": 16, +"pop": 1420000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Lubumbashi", +"country": "Congo (Kinshasa)", +"lat": -11.678, +"lon": 27.478, +"zoom": 16, +"pop": 1352000, +"region": "africa", +"rank": "primary" +}, +{ +"name": "Lusaka", +"country": "Zambia", +"lat": -15.415, +"lon": 28.281, +"zoom": 16, +"pop": 1328000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Mbuji-Mayi", +"country": "Congo (Kinshasa)", +"lat": -6.148, +"lon": 23.598, +"zoom": 16, +"pop": 1295000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Banghazi", +"country": "Libya", +"lat": 32.119, +"lon": 20.065, +"zoom": 16, +"pop": 1180000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Ouagadougou", +"country": "Burkina Faso", +"lat": 12.372, +"lon": -1.527, +"zoom": 16, +"pop": 1149000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Mogadishu", +"country": "Somalia", +"lat": 2.069, +"lon": 45.365, +"zoom": 16, +"pop": 1100000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Huambo", +"country": "Angola", +"lat": -12.748, +"lon": 15.758, +"zoom": 16, +"pop": 1100000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Monrovia", +"country": "Liberia", +"lat": 6.315, +"lon": -10.8, +"zoom": 16, +"pop": 1041000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Port Elizabeth", +"country": "South Africa", +"lat": -33.968, +"lon": 25.598, +"zoom": 16, +"pop": 1021000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Port Harcourt", +"country": "Nigeria", +"lat": 4.812, +"lon": 7.008, +"zoom": 16, +"pop": 1020000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Fez", +"country": "Morocco", +"lat": 34.057, +"lon": -5.002, +"zoom": 16, +"pop": 1002000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "N'Djamena", +"country": "Chad", +"lat": 12.115, +"lon": 15.047, +"zoom": 16, +"pop": 989000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Djibouti", +"country": "Djibouti", +"lat": 11.595, +"lon": 43.148, +"zoom": 16, +"pop": 923000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Niamey", +"country": "Niger", +"lat": 13.519, +"lon": 2.115, +"zoom": 16, +"pop": 915000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Kigali", +"country": "Rwanda", +"lat": -1.952, +"lon": 30.059, +"zoom": 16, +"pop": 860000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Bangui", +"country": "Central African Republic", +"lat": 4.367, +"lon": 18.558, +"zoom": 16, +"pop": 831925, +"region": "africa", +"rank": "backup" +}, +{ +"name": "Oran", +"country": "Algeria", +"lat": 35.712, +"lon": -0.622, +"zoom": 16, +"pop": 798000, +"region": "africa", +"rank": "backup" +}, +{ +"name": "São Paulo", +"country": "Brazil", +"lat": -23.557, +"lon": -46.627, +"zoom": 16, +"pop": 18845000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Buenos Aires", +"country": "Argentina", +"lat": -34.611, +"lon": -58.433, +"zoom": 16, +"pop": 12795000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Rio de Janeiro", +"country": "Brazil", +"lat": -22.907, +"lon": -43.212, +"zoom": 16, +"pop": 11748000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Lima", +"country": "Peru", +"lat": -12.046, +"lon": -77.052, +"zoom": 16, +"pop": 8012000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Bogota", +"country": "Colombia", +"lat": 4.598, +"lon": -74.085, +"zoom": 16, +"pop": 7772000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Santiago", +"country": "Chile", +"lat": -33.44, +"lon": -70.651, +"zoom": 16, +"pop": 5720000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Belo Horizonte", +"country": "Brazil", +"lat": -19.913, +"lon": -43.917, +"zoom": 16, +"pop": 5575000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Porto Alegre", +"country": "Brazil", +"lat": -30.048, +"lon": -51.202, +"zoom": 16, +"pop": 3917000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Caracas", +"country": "Venezuela", +"lat": 10.503, +"lon": -66.919, +"zoom": 16, +"pop": 2985000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Guayaquil", +"country": "Ecuador", +"lat": -2.218, +"lon": -79.922, +"zoom": 16, +"pop": 2514000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Cali", +"country": "Colombia", +"lat": 3.402, +"lon": -76.502, +"zoom": 16, +"pop": 2254000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Santa Cruz", +"country": "Bolivia", +"lat": -17.752, +"lon": -63.228, +"zoom": 16, +"pop": 2102998, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Maracaibo", +"country": "Venezuela", +"lat": 10.732, +"lon": -71.662, +"zoom": 16, +"pop": 2072000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Asunción", +"country": "Paraguay", +"lat": -25.291, +"lon": -57.626, +"zoom": 16, +"pop": 1870000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Barranquilla", +"country": "Colombia", +"lat": 10.962, +"lon": -74.802, +"zoom": 16, +"pop": 1798000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Quito", +"country": "Ecuador", +"lat": -0.213, +"lon": -78.502, +"zoom": 16, +"pop": 1701000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "La Paz", +"country": "Bolivia", +"lat": -16.496, +"lon": -68.152, +"zoom": 16, +"pop": 1590000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Córdoba", +"country": "Argentina", +"lat": -31.398, +"lon": -64.184, +"zoom": 16, +"pop": 1452000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Rosario", +"country": "Argentina", +"lat": -32.949, +"lon": -60.668, +"zoom": 16, +"pop": 1203000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Barquisimeto", +"country": "Venezuela", +"lat": 10.052, +"lon": -69.302, +"zoom": 16, +"pop": 1116000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Bucaramanga", +"country": "Colombia", +"lat": 7.132, +"lon": -73.128, +"zoom": 16, +"pop": 1009000, +"region": "south_america", +"rank": "primary" +}, +{ +"name": "Joinville", +"country": "Brazil", +"lat": -26.318, +"lon": -48.842, +"zoom": 16, +"pop": 988000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Natal", +"country": "Brazil", +"lat": -6.984, +"lon": -60.27, +"zoom": 16, +"pop": 980588, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "João Pessoa", +"country": "Brazil", +"lat": -7.099, +"lon": -34.878, +"zoom": 16, +"pop": 956000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Teresina", +"country": "Brazil", +"lat": -5.093, +"lon": -42.782, +"zoom": 16, +"pop": 907000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Concepción", +"country": "Chile", +"lat": -36.83, +"lon": -73.05, +"zoom": 16, +"pop": 889725, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Tucumán", +"country": "Argentina", +"lat": -26.814, +"lon": -65.219, +"zoom": 16, +"pop": 830000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Arequipa", +"country": "Peru", +"lat": -16.418, +"lon": -71.532, +"zoom": 16, +"pop": 815000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Cuiabá", +"country": "Brazil", +"lat": -15.568, +"lon": -56.087, +"zoom": 16, +"pop": 806000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Campo Grande", +"country": "Brazil", +"lat": -20.448, +"lon": -54.619, +"zoom": 16, +"pop": 778000, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Trujillo", +"country": "Peru", +"lat": -8.12, +"lon": -79.02, +"zoom": 16, +"pop": 765171, +"region": "south_america", +"rank": "backup" +}, +{ +"name": "Sydney", +"country": "Australia", +"lat": -33.871, +"lon": 151.213, +"zoom": 16, +"pop": 4630000, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Melbourne", +"country": "Australia", +"lat": -37.818, +"lon": 144.973, +"zoom": 16, +"pop": 4170000, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Brisbane", +"country": "Australia", +"lat": -27.453, +"lon": 153.033, +"zoom": 16, +"pop": 1860000, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Perth", +"country": "Australia", +"lat": -31.953, +"lon": 115.838, +"zoom": 16, +"pop": 1532000, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Auckland", +"country": "New Zealand", +"lat": -36.848, +"lon": 174.763, +"zoom": 16, +"pop": 1377200, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Wellington", +"country": "New Zealand", +"lat": -41.292, +"lon": 174.777, +"zoom": 16, +"pop": 393400, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Christchurch", +"country": "New Zealand", +"lat": -43.535, +"lon": 172.63, +"zoom": 16, +"pop": 363200, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Port Moresby", +"country": "Papua New Guinea", +"lat": -9.465, +"lon": 147.193, +"zoom": 16, +"pop": 283733, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Suva", +"country": "Fiji", +"lat": -18.133, +"lon": 178.442, +"zoom": 16, +"pop": 175399, +"region": "oceania", +"rank": "primary" +}, +{ +"name": "Townsville", +"country": "Australia", +"lat": -19.25, +"lon": 146.77, +"zoom": 16, +"pop": 138954, +"region": "oceania", +"rank": "backup" +}, +{ +"name": "Lae", +"country": "Papua New Guinea", +"lat": -6.733, +"lon": 146.99, +"zoom": 16, +"pop": 131052, +"region": "oceania", +"rank": "backup" +}, +{ +"name": "Tauranga", +"country": "New Zealand", +"lat": -37.696, +"lon": 176.154, +"zoom": 16, +"pop": 121500, +"region": "oceania", +"rank": "backup" +}, +{ +"name": "Dunedin", +"country": "New Zealand", +"lat": -45.885, +"lon": 170.491, +"zoom": 16, +"pop": 117700, +"region": "oceania", +"rank": "backup" +}, +{ +"name": "Kabul", +"country": "Afghanistan", +"lat": 34.519, +"lon": 69.181, +"zoom": 16, +"pop": 3277000, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Tashkent", +"country": "Uzbekistan", +"lat": 41.304, +"lon": 69.269, +"zoom": 16, +"pop": 2184000, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Almaty", +"country": "Kazakhstan", +"lat": 43.327, +"lon": 76.913, +"zoom": 16, +"pop": 1209000, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Dushanbe", +"country": "Tajikistan", +"lat": 38.56, +"lon": 68.774, +"zoom": 16, +"pop": 1086244, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Ulaanbaatar", +"country": "Mongolia", +"lat": 47.919, +"lon": 106.915, +"zoom": 16, +"pop": 885000, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Bishkek", +"country": "Kyrgyzstan", +"lat": 42.875, +"lon": 74.583, +"zoom": 16, +"pop": 837000, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Fargona", +"country": "Uzbekistan", +"lat": 40.39, +"lon": 71.78, +"zoom": 16, +"pop": 750000, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Ashgabat", +"country": "Turkmenistan", +"lat": 37.95, +"lon": 58.383, +"zoom": 16, +"pop": 727700, +"region": "central_asia", +"rank": "primary" +}, +{ +"name": "Kandahar", +"country": "Afghanistan", +"lat": 31.61, +"lon": 65.695, +"zoom": 16, +"pop": 715542, +"region": "central_asia", +"rank": "backup" +}, +{ +"name": "Herat", +"country": "Afghanistan", +"lat": 34.33, +"lon": 62.17, +"zoom": 16, +"pop": 481009, +"region": "central_asia", +"rank": "backup" +}, +{ +"name": "Mazar-i-Sharif", +"country": "Afghanistan", +"lat": 36.7, +"lon": 67.1, +"zoom": 16, +"pop": 458151, +"region": "central_asia", +"rank": "backup" +}, +{ +"name": "Qaraghandy", +"country": "Kazakhstan", +"lat": 49.885, +"lon": 73.115, +"zoom": 16, +"pop": 451800, +"region": "central_asia", +"rank": "backup" +}, +{ +"name": "Taraz", +"country": "Kazakhstan", +"lat": 42.9, +"lon": 71.365, +"zoom": 16, +"pop": 358153, +"region": "central_asia", +"rank": "backup" +}, +{ +"name": "Tokyo", +"country": "Japan", +"lat": 35.687, +"lon": 139.749, +"zoom": 16, +"pop": 35676000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "New York", +"country": "United States of America", +"lat": 40.722, +"lon": -73.996, +"zoom": 16, +"pop": 19040000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Mexico City", +"country": "Mexico", +"lat": 19.444, +"lon": -99.133, +"zoom": 16, +"pop": 19028000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Mumbai", +"country": "India", +"lat": 19.068, +"lon": 72.876, +"zoom": 16, +"pop": 18978000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Delhi", +"country": "India", +"lat": 28.672, +"lon": 77.228, +"zoom": 16, +"pop": 15926000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Shanghai", +"country": "China", +"lat": 31.218, +"lon": 121.435, +"zoom": 16, +"pop": 14987000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Kolkata", +"country": "India", +"lat": 22.57, +"lon": 88.369, +"zoom": 16, +"pop": 14787000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Dhaka", +"country": "Bangladesh", +"lat": 23.725, +"lon": 90.407, +"zoom": 16, +"pop": 12797394, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Los Angeles", +"country": "United States of America", +"lat": 34.049, +"lon": -118.232, +"zoom": 16, +"pop": 12500000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Karachi", +"country": "Pakistan", +"lat": 24.872, +"lon": 66.988, +"zoom": 16, +"pop": 12130000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Ōsaka", +"country": "Japan", +"lat": 34.691, +"lon": 135.504, +"zoom": 16, +"pop": 11294000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Beijing", +"country": "China", +"lat": 39.902, +"lon": 116.394, +"zoom": 16, +"pop": 11106000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Manila", +"country": "Philippines", +"lat": 14.606, +"lon": 120.98, +"zoom": 16, +"pop": 11100000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Moscow", +"country": "Russia", +"lat": 55.754, +"lon": 37.614, +"zoom": 16, +"pop": 10452000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Istanbul", +"country": "Turkey", +"lat": 41.018, +"lon": 28.974, +"zoom": 16, +"pop": 10061000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Paris", +"country": "France", +"lat": 48.858, +"lon": 2.353, +"zoom": 16, +"pop": 9904000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Seoul", +"country": "South Korea", +"lat": 37.568, +"lon": 126.998, +"zoom": 16, +"pop": 9796000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Jakarta", +"country": "Indonesia", +"lat": -6.172, +"lon": 106.827, +"zoom": 16, +"pop": 9125000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Chicago", +"country": "United States of America", +"lat": 41.848, +"lon": -87.635, +"zoom": 16, +"pop": 8990000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "London", +"country": "United Kingdom", +"lat": 51.502, +"lon": -0.119, +"zoom": 16, +"pop": 8567000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Tehran", +"country": "Iran", +"lat": 35.674, +"lon": 51.422, +"zoom": 16, +"pop": 7873000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Wuhan", +"country": "China", +"lat": 30.582, +"lon": 114.268, +"zoom": 16, +"pop": 7243000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Chennai", +"country": "India", +"lat": 13.092, +"lon": 80.278, +"zoom": 16, +"pop": 7163000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Taipei", +"country": "Taiwan", +"lat": 25.036, +"lon": 121.568, +"zoom": 16, +"pop": 6900273, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Bengaluru", +"country": "India", +"lat": 12.972, +"lon": 77.558, +"zoom": 16, +"pop": 6787000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Bangkok", +"country": "Thailand", +"lat": 13.752, +"lon": 100.515, +"zoom": 16, +"pop": 6704000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Lahore", +"country": "Pakistan", +"lat": 31.562, +"lon": 74.348, +"zoom": 16, +"pop": 6577000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Chongqing", +"country": "China", +"lat": 29.567, +"lon": 106.593, +"zoom": 16, +"pop": 6461000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Hyderabad", +"country": "India", +"lat": 17.402, +"lon": 78.478, +"zoom": 16, +"pop": 6376000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Miami", +"country": "United States of America", +"lat": 25.79, +"lon": -80.226, +"zoom": 16, +"pop": 5585000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Madrid", +"country": "Spain", +"lat": 40.402, +"lon": -3.685, +"zoom": 16, +"pop": 5567000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Ahmedabad", +"country": "India", +"lat": 23.032, +"lon": 72.578, +"zoom": 16, +"pop": 5375000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Ho Chi Minh City", +"country": "Vietnam", +"lat": 10.762, +"lon": 106.703, +"zoom": 16, +"pop": 5314000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Toronto", +"country": "Canada", +"lat": 43.665, +"lon": -79.389, +"zoom": 16, +"pop": 5213000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Singapore", +"country": "Singapore", +"lat": 1.295, +"lon": 103.854, +"zoom": 16, +"pop": 5183700, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Baghdad", +"country": "Iraq", +"lat": 33.341, +"lon": 44.392, +"zoom": 16, +"pop": 5054000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Barcelona", +"country": "Spain", +"lat": 41.385, +"lon": 2.181, +"zoom": 16, +"pop": 4920000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Shenyeng", +"country": "China", +"lat": 41.807, +"lon": 123.448, +"zoom": 16, +"pop": 4787000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "St. Petersburg", +"country": "Russia", +"lat": 59.941, +"lon": 30.314, +"zoom": 16, +"pop": 4553000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Chattogram", +"country": "Bangladesh", +"lat": 22.332, +"lon": 91.798, +"zoom": 16, +"pop": 4529000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Riyadh", +"country": "Saudi Arabia", +"lat": 24.634, +"lon": 46.72, +"zoom": 16, +"pop": 4465000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Hanoi", +"country": "Vietnam", +"lat": 21.035, +"lon": 105.848, +"zoom": 16, +"pop": 4378000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Guadalajara", +"country": "Mexico", +"lat": 20.672, +"lon": -103.332, +"zoom": 16, +"pop": 4198000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Chengdu", +"country": "China", +"lat": 30.672, +"lon": 104.068, +"zoom": 16, +"pop": 4123000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Yangon", +"country": "Myanmar", +"lat": 16.785, +"lon": 96.165, +"zoom": 16, +"pop": 4088000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Xian", +"country": "China", +"lat": 34.277, +"lon": 108.893, +"zoom": 16, +"pop": 4009000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Surat", +"country": "India", +"lat": 21.202, +"lon": 72.838, +"zoom": 16, +"pop": 3842000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Ankara", +"country": "Turkey", +"lat": 39.929, +"lon": 32.862, +"zoom": 16, +"pop": 3716000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Monterrey", +"country": "Mexico", +"lat": 25.672, +"lon": -100.332, +"zoom": 16, +"pop": 3712000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Nanjing", +"country": "China", +"lat": 32.052, +"lon": 118.778, +"zoom": 16, +"pop": 3679000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Montréal", +"country": "Canada", +"lat": 45.502, +"lon": -73.585, +"zoom": 16, +"pop": 3678000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Guiyang", +"country": "China", +"lat": 26.582, +"lon": 106.718, +"zoom": 16, +"pop": 3662000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Harbin", +"country": "China", +"lat": 45.752, +"lon": 126.648, +"zoom": 16, +"pop": 3621000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Ürümqi", +"country": "China", +"lat": 43.807, +"lon": 87.573, +"zoom": 16, +"pop": 3575000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Busan", +"country": "South Korea", +"lat": 35.097, +"lon": 129.008, +"zoom": 16, +"pop": 3480000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Berlin", +"country": "Germany", +"lat": 52.524, +"lon": 13.4, +"zoom": 16, +"pop": 3406000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Rome", +"country": "Italy", +"lat": 41.898, +"lon": 12.481, +"zoom": 16, +"pop": 3339000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Athens", +"country": "Greece", +"lat": 37.985, +"lon": 23.731, +"zoom": 16, +"pop": 3242000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Kanpur", +"country": "India", +"lat": 26.462, +"lon": 80.318, +"zoom": 16, +"pop": 3162000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Tel Aviv", +"country": "Israel", +"lat": 32.082, +"lon": 34.768, +"zoom": 16, +"pop": 3112000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Jeddah", +"country": "Saudi Arabia", +"lat": 21.519, +"lon": 39.217, +"zoom": 16, +"pop": 3012000, +"region": "rest", +"rank": "primary" +}, +{ +"name": "Milan", +"country": "Italy", +"lat": 45.472, +"lon": 9.203, +"zoom": 16, +"pop": 2945000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Stuttgart", +"country": "Germany", +"lat": 48.78, +"lon": 9.2, +"zoom": 16, +"pop": 2944700, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Kunming", +"country": "China", +"lat": 25.043, +"lon": 102.697, +"zoom": 16, +"pop": 2931000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Jaipur", +"country": "India", +"lat": 26.923, +"lon": 75.808, +"zoom": 16, +"pop": 2917000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Surabaya", +"country": "Indonesia", +"lat": -7.247, +"lon": 112.749, +"zoom": 16, +"pop": 2845000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Lisbon", +"country": "Portugal", +"lat": 38.725, +"lon": -9.147, +"zoom": 16, +"pop": 2812000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Fukuoka", +"country": "Japan", +"lat": 33.597, +"lon": 130.408, +"zoom": 16, +"pop": 2792000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Kaohsiung", +"country": "Taiwan", +"lat": 22.633, +"lon": 120.267, +"zoom": 16, +"pop": 2769072, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Katowice", +"country": "Poland", +"lat": 50.26, +"lon": 19.02, +"zoom": 16, +"pop": 2746000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Aleppo", +"country": "Syria", +"lat": 36.232, +"lon": 37.168, +"zoom": 16, +"pop": 2738000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Kyiv", +"country": "Ukraine", +"lat": 50.435, +"lon": 30.515, +"zoom": 16, +"pop": 2709000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Minneapolis", +"country": "United States of America", +"lat": 44.982, +"lon": -93.254, +"zoom": 16, +"pop": 2616000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "İzmir", +"country": "Turkey", +"lat": 38.438, +"lon": 27.15, +"zoom": 16, +"pop": 2587000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Sapporo", +"country": "Japan", +"lat": 43.077, +"lon": 141.338, +"zoom": 16, +"pop": 2544000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "George Town", +"country": "Malaysia", +"lat": 5.414, +"lon": 100.329, +"zoom": 16, +"pop": 2500000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "San Juan", +"country": "Puerto Rico", +"lat": 18.44, +"lon": -66.13, +"zoom": 16, +"pop": 2478905, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Mashhad", +"country": "Iran", +"lat": 36.272, +"lon": 59.568, +"zoom": 16, +"pop": 2469000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Damascus", +"country": "Syria", +"lat": 33.502, +"lon": 36.298, +"zoom": 16, +"pop": 2466000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Nagpur", +"country": "India", +"lat": 21.172, +"lon": 79.088, +"zoom": 16, +"pop": 2454000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Vienna", +"country": "Austria", +"lat": 48.202, +"lon": 16.365, +"zoom": 16, +"pop": 2400000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Sendai", +"country": "Japan", +"lat": 38.268, +"lon": 140.87, +"zoom": 16, +"pop": 2250000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Manchester", +"country": "United Kingdom", +"lat": 53.475, +"lon": -2.249, +"zoom": 16, +"pop": 2230000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Patna", +"country": "India", +"lat": 25.627, +"lon": 85.128, +"zoom": 16, +"pop": 2158000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Santo Domingo", +"country": "Dominican Republic", +"lat": 18.471, +"lon": -69.93, +"zoom": 16, +"pop": 2154000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Baku", +"country": "Azerbaijan", +"lat": 40.397, +"lon": 49.86, +"zoom": 16, +"pop": 2122300, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Medan", +"country": "Indonesia", +"lat": 3.582, +"lon": 98.648, +"zoom": 16, +"pop": 2115000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Kuwait City", +"country": "Kuwait", +"lat": 29.372, +"lon": 47.976, +"zoom": 16, +"pop": 2063000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Hiroshima", +"country": "Japan", +"lat": 34.39, +"lon": 132.441, +"zoom": 16, +"pop": 2045000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Sanaa", +"country": "Yemen", +"lat": 15.357, +"lon": 44.205, +"zoom": 16, +"pop": 2008000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Port-au-Prince", +"country": "Haiti", +"lat": 18.543, +"lon": -72.338, +"zoom": 16, +"pop": 1998000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Bucharest", +"country": "Romania", +"lat": 44.435, +"lon": 26.098, +"zoom": 16, +"pop": 1942000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Rawalpindi", +"country": "Pakistan", +"lat": 33.591, +"lon": 73.068, +"zoom": 16, +"pop": 1858000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Minsk", +"country": "Belarus", +"lat": 53.902, +"lon": 27.565, +"zoom": 16, +"pop": 1805000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Hamburg", +"country": "Germany", +"lat": 53.552, +"lon": 9.998, +"zoom": 16, +"pop": 1757000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Palembang", +"country": "Indonesia", +"lat": -2.978, +"lon": 104.748, +"zoom": 16, +"pop": 1749000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Warsaw", +"country": "Poland", +"lat": 52.231, +"lon": 21.005, +"zoom": 16, +"pop": 1707000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Budapest", +"country": "Hungary", +"lat": 47.502, +"lon": 19.081, +"zoom": 16, +"pop": 1679000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Isfahan", +"country": "Iran", +"lat": 32.702, +"lon": 51.698, +"zoom": 16, +"pop": 1628000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Tijuana", +"country": "Mexico", +"lat": 32.502, +"lon": -117.082, +"zoom": 16, +"pop": 1553000, +"region": "rest", +"rank": "backup" +}, +{ +"name": "Multan", +"country": "Pakistan", +"lat": 30.202, +"lon": 71.453, +"zoom": 16, +"pop": 1522000, +"region": "rest", +"rank": "backup" +} +] \ No newline at end of file diff --git a/intro-sequence/tools/flurry/landmarks.py b/intro-sequence/tools/flurry/landmarks.py new file mode 100644 index 000000000..5cadca009 --- /dev/null +++ b/intro-sequence/tools/flurry/landmarks.py @@ -0,0 +1,205 @@ +"""Hand-picked non-city landmarks for the flurry montage. + +(name, country, lat, lon, zoom). zoom 16 for compact features, 15 for very large +ones (deltas, glaciers, big pits, pivot fields). Coordinates are verified +visually via tools/flurry/review.py sheets; entries that came back as featureless +terrain were corrected or dropped (see REJECTED at the bottom). +""" + +LANDMARKS = [ + # --- ports / container terminals --- + ("Port of Rotterdam, Maasvlakte", "Netherlands", 51.9575, 4.0500, 16), + ("Port of Singapore, Pasir Panjang", "Singapore", 1.2735, 103.7750, 16), + ("Yangshan Deep-Water Port", "China", 30.6280, 122.0650, 16), + ("Port of Los Angeles, Pier 400", "United States of America", 33.7350, -118.2550, 16), + ("Jebel Ali Port", "United Arab Emirates", 25.0100, 55.0600, 16), + ("Port of Santos", "Brazil", -23.9700, -46.3000, 16), + ("Port of Hamburg, Burchardkai", "Germany", 53.5350, 9.9200, 16), + ("Port Hedland salt ponds", "Australia", -20.3450, 118.6550, 15), + ("Busan New Port", "South Korea", 35.0800, 128.8000, 16), + ("Tanger Med Port", "Morocco", 35.8850, -5.5000, 16), + ("Port of Tema", "Ghana", 5.6330, 0.0100, 16), + # --- airports --- + ("Hartsfield-Jackson Atlanta Airport", "United States of America", 33.6407, -84.4277, 16), + ("Dubai International Airport", "United Arab Emirates", 25.2528, 55.3644, 16), + ("Hong Kong International Airport", "Hong Kong S.A.R.", 22.3080, 113.9185, 16), + ("Denver International Airport", "United States of America", 39.8494, -104.6738, 16), + ("Kansai International Airport", "Japan", 34.4320, 135.2304, 16), + ("Beijing Daxing Airport", "China", 39.5098, 116.4105, 16), + ("Davis-Monthan Aircraft Boneyard", "United States of America", 32.1500, -110.8300, 16), + ("Princess Juliana Airport", "Sint Maarten", 18.0410, -63.1089, 16), + ("Gibraltar Airport runway", "Gibraltar", 36.1512, -5.3497, 16), + ("Faa'a International Airport", "French Polynesia", -17.5537, -149.6067, 16), + ("Funafuti Airfield", "Tuvalu", -8.5250, 179.1960, 16), + ("Midway Atoll, Sand Island", "United States of America", 28.2100, -177.3800, 16), + ("Wake Island Airfield", "United States of America", 19.2820, 166.6470, 16), + # --- dams --- + ("Hoover Dam", "United States of America", 36.0160, -114.7377, 16), + ("Three Gorges Dam", "China", 30.8233, 111.0036, 16), + ("Itaipu Dam", "Brazil", -25.4085, -54.5888, 16), + ("Grand Coulee Dam", "United States of America", 47.9560, -118.9817, 16), + ("Kariba Dam", "Zimbabwe", -16.5222, 28.7614, 16), + ("Aswan High Dam", "Egypt", 23.9707, 32.8780, 16), + ("Grand Ethiopian Renaissance Dam", "Ethiopia", 11.2150, 35.0900, 16), + ("Glen Canyon Dam", "United States of America", 36.9375, -111.4844, 16), + ("Akosombo Dam", "Ghana", 6.2999, 0.0592, 16), + ("Inga Dams", "Congo (Kinshasa)", -5.5240, 13.6200, 16), + # --- open-pit mines --- + ("Bingham Canyon Mine", "United States of America", 40.5230, -112.1510, 15), + ("Chuquicamata Mine", "Chile", -22.2900, -68.9000, 15), + ("Mir Diamond Mine", "Russia", 62.5275, 113.9936, 15), + ("Escondida Mine", "Chile", -24.2700, -69.0700, 15), + ("Kalgoorlie Super Pit", "Australia", -30.7775, 121.5040, 15), + ("Udachnaya Pipe", "Russia", 66.4300, 112.3200, 15), + ("Garzweiler Lignite Mine", "Germany", 51.0600, 6.4800, 15), + ("Kimberley Big Hole", "South Africa", -28.7383, 24.7585, 16), + ("Diavik Diamond Mine", "Canada", 64.4960, -110.2700, 15), + ("Syncrude Mildred Lake oil sands", "Canada", 57.0400, -111.6000, 15), + # --- solar --- + ("Noor Ouarzazate Solar", "Morocco", 31.0498, -6.8686, 16), + ("Ivanpah Solar", "United States of America", 35.5525, -115.4680, 16), + ("Crescent Dunes Solar", "United States of America", 38.2386, -117.3636, 15), + ("Bhadla Solar Park", "India", 27.5400, 71.9100, 16), + ("Gemasolar", "Spain", 37.5606, -5.3317, 16), + ("PS10 / PS20 Solar Towers", "Spain", 37.4425, -6.2550, 16), + ("Benban Solar Park", "Egypt", 24.4500, 32.7300, 16), + ("Cerro Dominador Solar", "Chile", -22.7700, -69.4800, 16), + # --- centre-pivot irrigation --- + ("Centre-pivot fields, Kansas", "United States of America", 37.9000, -100.9000, 15), + ("Centre-pivot fields, Wadi As-Sirhan", "Saudi Arabia", 30.2000, 38.0800, 15), + ("Kufra agricultural project", "Libya", 24.1400, 23.3500, 15), + ("East Oweinat pivots", "Egypt", 22.6000, 28.6000, 15), + ("Centre-pivot fields, Nebraska", "United States of America", 41.1000, -101.7000, 15), + # --- river deltas / water --- + ("Lena Delta", "Russia", 72.8000, 126.5000, 15), + ("Okavango Delta", "Botswana", -19.3000, 22.9000, 15), + ("Betsiboka Estuary", "Madagascar", -15.7500, 46.3500, 15), + ("Meeting of the Waters, Manaus", "Brazil", -3.1370, -59.9100, 15), + # --- salt / evaporation --- + ("Salar de Atacama lithium ponds", "Chile", -23.5280, -68.3680, 16), + ("Dead Sea evaporation ponds", "Israel", 31.1000, 35.4000, 15), + ("San Francisco Bay salt ponds", "United States of America", 37.5000, -122.0500, 15), + ("Lake Retba (Pink Lake)", "Senegal", 14.8400, -17.2300, 15), + ("Hutt Lagoon", "Australia", -28.1700, 114.2500, 15), + # --- glaciers / ice --- + ("Perito Moreno Glacier", "Argentina", -50.4800, -73.0500, 15), + ("Aletsch Glacier, Konkordiaplatz", "Switzerland", 46.5000, 8.0300, 15), + ("Hubbard Glacier", "United States of America", 60.0200, -139.5000, 15), + # Antarctica: Esri has only Landsat-class imagery here (z14+ is placeholder), + # so these use z11-12 where the source pixels are native rather than upsampled. + ("Taylor Valley, Canada Glacier", "Antarctica", -77.6300, 162.9500, 12), + ("McMurdo Dry Valleys, Wright Valley", "Antarctica", -77.5700, 161.8500, 11), + ("Deception Island caldera", "Antarctica", -62.9600, -60.6300, 12), + ("Mount Everest", "Nepal", 27.9881, 86.9250, 15), + # --- volcanoes / craters --- + ("Mount Bromo", "Indonesia", -7.9420, 112.9530, 15), + ("Erta Ale", "Ethiopia", 13.6065, 40.6655, 16), + ("Kilauea, Halemaumau", "United States of America", 19.4060, -155.2830, 15), + ("Mount Fuji summit crater", "Japan", 35.3628, 138.7307, 16), + ("Nyiragongo", "Congo (Kinshasa)", -1.5200, 29.2500, 16), + ("Meteor Crater", "United States of America", 35.0275, -111.0225, 15), + ("Mount St. Helens", "United States of America", 46.2000, -122.1900, 15), + ("Kawah Ijen", "Indonesia", -8.0580, 114.2420, 16), + ("Teide", "Spain", 28.2725, -16.6425, 16), + ("Mount Vesuvius", "Italy", 40.8216, 14.4263, 16), + ("Rano Kau, Easter Island", "Chile", -27.1860, -109.4350, 15), + ("Diamond Head", "United States of America", 21.2620, -157.8050, 15), + ("Darvaza Gas Crater", "Turkmenistan", 40.2525, 58.4394, 16), + ("Kilimanjaro, Kibo crater", "Tanzania", -3.0758, 37.3533, 15), + ("Mount Taranaki", "New Zealand", -39.2968, 174.0634, 15), + ("Whakaari / White Island", "New Zealand", -37.5200, 177.1800, 15), + ("Dallol", "Ethiopia", 14.2400, 40.3000, 16), + # --- stadiums / venues --- + ("Maracana Stadium", "Brazil", -22.9122, -43.2302, 16), + ("Rungrado May Day Stadium", "North Korea", 39.0497, 125.7756, 16), + ("Camp Nou", "Spain", 41.3809, 2.1228, 16), + ("Beijing National Stadium", "China", 39.9925, 116.3910, 16), + ("Melbourne Cricket Ground", "Australia", -37.8200, 144.9834, 16), + ("Indianapolis Motor Speedway", "United States of America", 39.7950, -86.2350, 15), + ("Estadio Azteca", "Mexico", 19.3029, -99.1505, 16), + ("FNB Stadium, Soweto", "South Africa", -26.2348, 27.9825, 16), + # --- bridges / canals --- + ("Golden Gate Bridge", "United States of America", 37.8100, -122.4770, 16), + ("Sydney Harbour Bridge & Opera House", "Australia", -33.8550, 151.2120, 16), + ("Tower Bridge", "United Kingdom", 51.5055, -0.0754, 16), + ("Oresund Bridge, Peberholm", "Denmark", 55.5900, 12.7740, 16), + ("Millau Viaduct", "France", 44.0794, 3.0225, 16), + ("Brooklyn Bridge", "United States of America", 40.7061, -73.9969, 16), + ("Panama Canal, Miraflores Locks", "Panama", 8.9970, -79.5915, 16), + ("Panama Canal, Agua Clara Locks", "Panama", 9.2720, -79.9180, 16), + # --- launch sites --- + ("Kennedy Space Center LC-39A", "United States of America", 28.6083, -80.6041, 16), + ("Baikonur, Gagarin's Start", "Kazakhstan", 45.9203, 63.3422, 16), + ("Vandenberg SLC-4E", "United States of America", 34.6320, -120.6110, 16), + ("Starbase, Boca Chica", "United States of America", 25.9972, -97.1566, 16), + ("Wenchang Launch Site", "China", 19.6145, 110.9510, 16), + ("Jiuquan Launch Centre", "China", 40.9580, 100.2910, 16), + ("Tanegashima Space Center", "Japan", 30.4009, 130.9749, 16), + ("Vostochny Cosmodrome", "Russia", 51.8840, 128.3330, 16), + ("Rocket Lab LC-1, Mahia", "New Zealand", -39.2615, 177.8649, 16), + # --- energy / industry --- + ("Permian Basin well pads", "United States of America", 32.2100, -103.8300, 15), + ("Ras Tanura refinery", "Saudi Arabia", 26.6400, 50.1600, 16), + ("Jamnagar Refinery", "India", 22.3500, 69.8500, 16), + ("Ras Laffan Industrial City", "Qatar", 25.9100, 51.5500, 16), + ("Yamal LNG, Sabetta", "Russia", 71.2700, 72.0700, 16), + ("Chernobyl Nuclear Power Plant", "Ukraine", 51.3890, 30.0990, 16), + # --- monuments / sites --- + ("Jiayuguan Pass, Great Wall", "China", 39.8016, 98.2133, 16), + ("Pyramids of Giza", "Egypt", 29.9792, 31.1342, 16), + ("Palm Jumeirah", "United Arab Emirates", 25.1124, 55.1390, 15), + ("Angkor Wat", "Cambodia", 13.4125, 103.8670, 16), + ("The Pentagon", "United States of America", 38.8719, -77.0563, 16), + ("Groom Lake", "United States of America", 37.2350, -115.8111, 15), + ("FAST Radio Telescope", "China", 25.6529, 106.8566, 16), + ("Very Large Array", "United States of America", 34.0784, -107.6184, 16), + ("Uluru", "Australia", -25.3444, 131.0369, 15), + ("Mont-Saint-Michel", "France", 48.6361, -1.5115, 16), + ("Grand Prismatic Spring", "United States of America", 44.5251, -110.8382, 16), + ("Masjid al-Haram, Mecca", "Saudi Arabia", 21.4225, 39.8262, 16), + ("Forbidden City", "China", 39.9155, 116.3910, 16), + ("Taj Mahal", "India", 27.1751, 78.0421, 16), + ("Greenhouses of Almeria", "Spain", 36.7500, -2.7200, 16), + ("Pine Gap", "Australia", -23.7990, 133.7370, 16), + ("ALMA Observatory", "Chile", -23.0229, -67.7552, 16), + ("Paranal Observatory", "Chile", -24.6275, -70.4044, 16), + ("Brasilia, National Congress", "Brazil", -15.7998, -47.8645, 16), + ("Iguazu Falls, Devil's Throat", "Argentina", -25.6950, -54.4367, 16), + ("Niagara Horseshoe Falls", "Canada", 43.0773, -79.0757, 16), + ("Victoria Falls", "Zambia", -17.9243, 25.8572, 16), + ("Horseshoe Bend", "United States of America", 36.8791, -111.5103, 16), + ("Sossusvlei dunes", "Namibia", -24.7300, 15.2900, 15), + ("Erg Chebbi dunes", "Morocco", 31.1500, -3.9800, 15), + ("Bora Bora", "French Polynesia", -16.4950, -151.7050, 15), + ("Borobudur", "Indonesia", -7.6079, 110.2038, 16), + ("Bagan temples", "Myanmar", 21.1700, 94.8600, 16), + ("Svalbard, Longyearbyen", "Norway", 78.2200, 15.6500, 16), + ("Edinburgh of the Seven Seas, Tristan da Cunha", "United Kingdom", -37.0670, -12.3100, 16), + ("Diego Garcia", "United Kingdom", -7.3100, 72.4100, 15), + ("Kwajalein Atoll", "Marshall Islands", 8.7200, 167.7300, 16), +] + +REJECTED = { + 'Pituffik Space Base': 'placeholder above z13; z13 too blurry', + 'Ilulissat Icefjord': 'placeholder above z13; open water at z13', + 'McMurdo Station': 'placeholder above z13; blurry ice at z13', + 'Rothera Research Station': 'placeholder above z13; mostly sea', + 'Mount Erebus crater': 'placeholder above z13; cloud-like blur', + 'Port of Durban': "featureless/cloud/low-res/off-target at tested coords", + 'Port of Mombasa': "featureless/cloud/low-res/off-target at tested coords", + 'Sundarbans, Ganges Delta': "featureless/cloud/low-res/off-target at tested coords", + "Mississippi Bird's-Foot Delta": "featureless/cloud/low-res/off-target at tested coords", + 'Volga Delta': "featureless/cloud/low-res/off-target at tested coords", + 'Great Salt Lake causeway': "featureless/cloud/low-res/off-target at tested coords", + 'Jokulsarlon Glacier Lagoon': "featureless/cloud/low-res/off-target at tested coords", + 'Baltoro Glacier, Concordia': "featureless/cloud/low-res/off-target at tested coords", + 'Cotopaxi': "featureless/cloud/low-res/off-target at tested coords", + 'Guiana Space Centre ELA-3': "featureless/cloud/low-res/off-target at tested coords", + 'Satish Dhawan Space Centre': "featureless/cloud/low-res/off-target at tested coords", + 'Abqaiq processing plant': "featureless/cloud/low-res/off-target at tested coords", + 'Prudhoe Bay oil field': "featureless/cloud/low-res/off-target at tested coords", + 'Great Wall at Mutianyu': "featureless/cloud/low-res/off-target at tested coords", + 'Machu Picchu': "featureless/cloud/low-res/off-target at tested coords", + 'Baltra Island airport, Galapagos': "featureless/cloud/low-res/off-target at tested coords", + 'Moynaq ship graveyard, Aral Sea': "featureless/cloud/low-res/off-target at tested coords", +} diff --git a/intro-sequence/tools/flurry/locate.py b/intro-sequence/tools/flurry/locate.py new file mode 100644 index 000000000..d880ea3b6 --- /dev/null +++ b/intro-sequence/tools/flurry/locate.py @@ -0,0 +1,78 @@ +"""Locator sheets: a wider mosaic around a guess with a lat/lon grid, to find exact coords. + +usage: python3 locate.py out.jpg "name|lat|lon|z|ntiles" ... +Each panel is ntiles x ntiles tiles at zoom z (resized to 640px) with grid +lines every `step` degrees, labelled. Panels are stacked in a 2-column grid. +""" +import io +import math +import os +import sys +from concurrent.futures import ThreadPoolExecutor + +from PIL import Image, ImageDraw + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from tiles import fetch_tile, pixel_xy # noqa: E402 + +P = 640 + + +def panel(name, lat, lon, z, nt): + px, py = pixel_xy(lat, lon, z) + tx, ty = int(px // 256), int(py // 256) + h = nt // 2 + coords = [(tx - h + i, ty - h + j) for j in range(nt) for i in range(nt)] + with ThreadPoolExecutor(8) as ex: + datas = list(ex.map(lambda c: fetch_tile(z, c[0], c[1]), coords)) + m = Image.new("RGB", (nt * 256, nt * 256)) + for (x, y), d in zip(coords, datas): + m.paste(Image.open(io.BytesIO(d)).convert("RGB"), ((x - tx + h) * 256, (y - ty + h) * 256)) + ox, oy = (tx - h) * 256, (ty - h) * 256 + s = P / (nt * 256) + m = m.resize((P, P), Image.LANCZOS) + d = ImageDraw.Draw(m) + n = 2 ** z * 256 + + def lon2x(lo): + return ((lo + 180) / 360 * n - ox) * s + + def lat2y(la): + si = math.sin(math.radians(la)) + return ((0.5 - math.log((1 + si) / (1 - si)) / (4 * math.pi)) * n - oy) * s + + span = nt * 256 / n * 360 + step = [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][ + min(range(9), key=lambda k: abs(span / [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5][k] - 6))] + lo0 = math.floor((lon - span) / step) * step + for k in range(40): + lo = lo0 + k * step + x = lon2x(lo) + if 0 <= x <= P: + d.line((x, 0, x, P), fill=(255, 255, 0), width=1) + d.text((x + 2, P - 12), f"{lo:.3f}", fill=(255, 255, 0)) + la0 = math.floor((lat - span) / step) * step + for k in range(40): + la = la0 + k * step + y = lat2y(la) + if 0 <= y <= P: + d.line((0, y, P, y), fill=(0, 255, 255), width=1) + d.text((2, y + 1), f"{la:.3f}", fill=(0, 255, 255)) + cx, cy = lon2x(lon), lat2y(lat) + d.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), outline=(255, 0, 0), width=2) + d.rectangle((0, 0, P, 14), fill=(0, 0, 0)) + d.text((3, 1), f"{name} z{z}", fill=(255, 255, 255)) + return m + + +if __name__ == "__main__": + out = sys.argv[1] + specs = [a.split("|") for a in sys.argv[2:]] + ps = [panel(s[0], float(s[1]), float(s[2]), int(s[3]), int(s[4])) for s in specs] + cols = 2 if len(ps) > 1 else 1 + rows = (len(ps) + cols - 1) // cols + sheet = Image.new("RGB", (cols * P, rows * P)) + for i, p in enumerate(ps): + sheet.paste(p, ((i % cols) * P, (i // cols) * P)) + sheet.save(out, quality=85) + print(out) diff --git a/intro-sequence/tools/flurry/rejects.json b/intro-sequence/tools/flurry/rejects.json new file mode 100644 index 000000000..c7da764a9 --- /dev/null +++ b/intro-sequence/tools/flurry/rejects.json @@ -0,0 +1,32 @@ +[ + { + "name": "Venice", + "country": "Italy", + "reason": "visual: blurry low-res imagery" + }, + { + "name": "Chengdu", + "country": "China", + "reason": "visual: smog haze" + }, + { + "name": "Xian", + "country": "China", + "reason": "visual: washed-out industrial lot" + }, + { + "name": "Maracaibo", + "country": "Venezuela", + "reason": "visual: farmland / scrub" + }, + { + "name": "Barquisimeto", + "country": "Venezuela", + "reason": "visual: dry riverbed, no city" + }, + { + "name": "Conakry", + "country": "Guinea", + "reason": "visual: heavy haze" + } +] \ No newline at end of file diff --git a/intro-sequence/tools/flurry/review.py b/intro-sequence/tools/flurry/review.py new file mode 100644 index 000000000..281b76917 --- /dev/null +++ b/intro-sequence/tools/flurry/review.py @@ -0,0 +1,69 @@ +"""Fetch candidates and render labelled review sheets for visual verification. + +usage: python3 review.py landmarks|cities [start] [count] +Writes tools/flurry/review/_NN.jpg (5x4 grid, 256px cells, labelled). +""" +import json +import os +import sys +from concurrent.futures import ThreadPoolExecutor + +from PIL import Image, ImageDraw + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from tiles import HERE, MAX_WORKERS, capture, quality_ok # noqa: E402 + +REV = os.path.join(HERE, "review") + + +def load(kind): + if kind == "landmarks": + from landmarks import LANDMARKS + return [dict(name=n, country=c, lat=la, lon=lo, zoom=z) for n, c, la, lo, z in LANDMARKS] + return json.load(open(os.path.join(HERE, "city_candidates.json"))) + + +def run(cands, prefix, cell=256, cols=5, rows=4): + os.makedirs(REV, exist_ok=True) + + def job(c): + try: + img, info = capture(c["lat"], c["lon"], c["zoom"]) + return img, info + except Exception as e: + return None, {"error": str(e)} + + with ThreadPoolExecutor(MAX_WORKERS) as ex: + res = list(ex.map(job, cands)) + per = cols * rows + report = [] + for s in range(0, len(cands), per): + sheet = Image.new("RGB", (cols * cell, rows * cell), (20, 20, 20)) + d = ImageDraw.Draw(sheet) + for k, (c, (img, info)) in enumerate(zip(cands[s:s + per], res[s:s + per])): + x, y = (k % cols) * cell, (k // cols) * cell + idx = s + k + if img is not None: + sheet.paste(img.resize((cell, cell), Image.LANCZOS), (x, y)) + ok, why = quality_ok(info) + lab2 = f"s{info['std']:.0f} g{info['grad']:.1f}" + ("" if ok else " X " + why[:18]) + else: + ok, lab2 = False, "ERR" + d.rectangle((x, y, x + cell, y + 24), fill=(0, 0, 0)) + d.text((x + 3, y + 1), f"{idx} {c['name'][:36]}", fill=(255, 255, 0)) + d.text((x + 3, y + 12), lab2, fill=(255, 120, 120) if not ok else (160, 255, 160)) + report.append((idx, c["name"], ok, lab2)) + out = os.path.join(REV, f"{prefix}_{s // per:02d}.jpg") + sheet.save(out, quality=85) + print(out) + for r in report: + if not r[2]: + print("REJECT", r) + + +if __name__ == "__main__": + kind = sys.argv[1] + cands = load(kind) + st = int(sys.argv[2]) if len(sys.argv) > 2 else 0 + n = int(sys.argv[3]) if len(sys.argv) > 3 else len(cands) + run(cands[st:st + n], kind[:2] + (f"_{st}" if st else "")) diff --git a/intro-sequence/tools/flurry/select_cities.py b/intro-sequence/tools/flurry/select_cities.py new file mode 100644 index 000000000..16b8e9367 --- /dev/null +++ b/intro-sequence/tools/flurry/select_cities.py @@ -0,0 +1,109 @@ +"""Pick ~170 city centres from data/cities.json with geographic spread. + +Writes tools/flurry/city_candidates.json: ordered list of dicts with +rank ("primary"/"backup") and region. Backups replace rejected primaries of the +same region in build.py. +""" +import json +import math +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + +AFRICA = { + "Nigeria", "Egypt", "Congo (Kinshasa)", "South Africa", "Algeria", "Sudan", "Morocco", "Tanzania", + "Cameroon", "Mozambique", "Libya", "Ethiopia", "Ivory Coast", "Tunisia", "Zambia", "Angola", "Ghana", + "Uganda", "Kenya", "Guinea", "Madagascar", "Senegal", "Zimbabwe", "Mali", "Somalia", "Chad", "Niger", + "Benin", "Eritrea", "Somaliland", "South Sudan", "Sierra Leone", "Mauritania", "Malawi", "Togo", + "Congo (Brazzaville)", "Burkina Faso", "Rwanda", "Mauritius", "Gabon", "Equatorial Guinea", "Liberia", + "Djibouti", "Central African Republic", "Guinea Bissau", "Lesotho", "Burundi", "Namibia", "Botswana", + "The Gambia", "Comoros", "Cape Verde", "eSwatini", +} +SOUTH_AMERICA = {"Brazil", "Argentina", "Colombia", "Venezuela", "Peru", "Chile", "Ecuador", "Bolivia", + "Paraguay", "Uruguay", "Guyana", "Suriname"} +OCEANIA = {"Australia", "New Zealand", "Papua New Guinea", "Fiji", "French Polynesia", "Guam"} +CENTRAL_ASIA = {"Kazakhstan", "Uzbekistan", "Turkmenistan", "Kyrgyzstan", "Tajikistan", "Afghanistan", "Mongolia"} + +# (region, primary quota, backups, per-country cap, min spacing km) +PASSES = [ + ("africa", AFRICA, 31, 16, 3, 300), + ("south_america", SOUTH_AMERICA, 22, 10, 5, 250), + ("oceania", OCEANIA, 12, 4, 5, 120), + ("central_asia", CENTRAL_ASIA, 9, 5, 3, 150), + ("rest", None, 96, 40, 11, 200), +] +# Picked first (in "rest" unless their country is in a quota region): fill the +# Pacific / high-latitude longitude bands and add visually distinctive cities. +FORCED = [ + "Honolulu", "Anchorage", "Papeete", "Agana", "Petropavlovsk-Kamchatsky", "Vancouver", "San Francisco", + "Las Vegas", "Seattle", "Phoenix", "Denver", "Calgary", "Guatemala City", "Havana", "Reykjavík", + "Dubai", "Doha", "Abu Dhabi", "Hong Kong", "Macau", "Venice", "Amsterdam", "København", "Oslo", + "Stockholm", "Helsinki", "Prague", "Kathmandu", "Colombo", "Vladivostok", "Novosibirsk", "Irkutsk", + "Yakutsk", "Norilsk", "Murmansk", "Manaus", "Mombasa", "Windhoek", "Nouakchott", "Marrakesh", + "Samarkand", "Cairns", "Muscat", "Québec", +] +# Natural Earth rows whose point lands in farmland/hills rather than a dense centre. +EXCLUDE = {"Amaravati", "Hechi", "Saidu", "Cranbourne", "Manukau", "Waitakere", "North Shore", "Wahiawa"} +COUNTRY_CAP_TOTAL = 12 + + +def km(a, b): + la1, lo1, la2, lo2 = map(math.radians, (a[1], a[0], b[1], b[0])) + h = math.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2 + return 6371 * 2 * math.asin(math.sqrt(h)) + + +def main(): + rows = json.load(open(os.path.join(ROOT, "data", "cities.json")))["rows"] + special = AFRICA | SOUTH_AMERICA | OCEANIA | CENTRAL_ASIA + chosen, out = [], [] + byname = {} + for r in rows: + byname.setdefault(r[3], r) # first = most populous + per = {} + + def region_of(country): + for reg, mem, *_ in PASSES: + if mem and country in mem: + return reg + return "rest" + + for nm in FORCED: + lon, lat, pop, name, country = byname[nm] + chosen.append((lon, lat)) + per[country] = per.get(country, 0) + 1 + out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop, + region=region_of(country), rank="primary", forced=True)) + for region, members, quota, nback, cap, spacing in PASSES: + got = sum(1 for c in out if c["region"] == region) + for lon, lat, pop, name, country in rows: + if name in EXCLUDE or name in byname and any(c["name"] == name and c["country"] == country for c in out): + continue + inreg = (country in members) if members else (country not in special) + if not inreg: + continue + if per.get(country, 0) >= cap and got < quota or per.get(country, 0) >= COUNTRY_CAP_TOTAL: + continue + if any(km((lon, lat), (c[0], c[1])) < spacing for c in chosen): + continue + rank = "primary" if got < quota else "backup" + chosen.append((lon, lat)) + per[country] = per.get(country, 0) + 1 + out.append(dict(name=name, country=country, lat=lat, lon=lon, zoom=16, pop=pop, + region=region, rank=rank)) + got += 1 + if got >= quota + nback: + break + json.dump(out, open(os.path.join(HERE, "city_candidates.json"), "w"), indent=0, ensure_ascii=False) + prim = [c for c in out if c["rank"] == "primary"] + print(len(prim), "primary,", len(out) - len(prim), "backup") + from collections import Counter + print(Counter(c["region"] for c in prim)) + print(Counter(c["country"] for c in prim).most_common(12)) + bands = Counter(int((c["lon"] + 180) // 30) for c in prim) + print("30deg bands:", [bands.get(i, 0) for i in range(12)]) + + +if __name__ == "__main__": + main() diff --git a/intro-sequence/tools/flurry/tiles.py b/intro-sequence/tools/flurry/tiles.py new file mode 100644 index 000000000..caaae300a --- /dev/null +++ b/intro-sequence/tools/flurry/tiles.py @@ -0,0 +1,132 @@ +"""Polite cached Esri World Imagery fetch + 512px centred capture + quality metrics.""" +import hashlib +import io +import math +import os +import threading +import time + +import numpy as np +import requests +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +CACHE = os.path.join(HERE, "cache") +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +OUT = os.path.join(ROOT, "imagery", "flurry") + +ESRI = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" +UA = "gods-eye-view-intro/1.0 (flurry montage prep)" +MAX_WORKERS = 8 +PLACEHOLDER_MD5 = {"f27d9de7f80c13501f470595e327aa6d"} # "Map data not yet available" + +_local = threading.local() + + +def _session(): + s = getattr(_local, "s", None) + if s is None: + s = requests.Session() + s.headers["User-Agent"] = UA + _local.s = s + return s + + +def fetch_tile(z, x, y, retries=6): + n = 2 ** z + x %= n + path = os.path.join(CACHE, str(z), str(x), f"{y}.jpg") + if os.path.exists(path) and os.path.getsize(path) > 0: + with open(path, "rb") as f: + return f.read() + os.makedirs(os.path.dirname(path), exist_ok=True) + url = ESRI.format(z=z, x=x, y=y) + last = None + for attempt in range(retries): + try: + r = _session().get(url, timeout=45) + if r.status_code == 200 and r.content: + tmp = path + f".{threading.get_ident()}.part" + with open(tmp, "wb") as f: + f.write(r.content) + os.replace(tmp, path) + return r.content + last = f"HTTP {r.status_code}" + if r.status_code in (400, 404): + break + except Exception as e: + last = repr(e) + time.sleep(min(20, 1.0 * 2 ** attempt)) + raise RuntimeError(f"failed {url}: {last}") + + +def is_placeholder(data): + if hashlib.md5(data).hexdigest() in PLACEHOLDER_MD5: + return True + a = np.asarray(Image.open(io.BytesIO(data)).convert("RGB")).astype(np.float32) + m = a.mean(axis=(0, 1)) + return bool(abs(m - 204.7).max() < 4 and a.std() < 9) + + +def pixel_xy(lat, lon, z): + n = 2 ** z * 256 + px = (lon + 180.0) / 360.0 * n + s = math.sin(math.radians(lat)) + py = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n + return px, py + + +def capture(lat, lon, z, size=512): + """Return (image, info). info has placeholder flag and quality metrics.""" + px, py = pixel_xy(lat, lon, z) + tx, ty = int(px // 256), int(py // 256) + mosaic = Image.new("RGB", (768, 768)) + ox, oy = (tx - 1) * 256, (ty - 1) * 256 + left, top = int(round(px - ox - size / 2)), int(round(py - oy - size / 2)) + ph = False + for j in range(3): + for i in range(3): + data = fetch_tile(z, tx - 1 + i, ty - 1 + j) + # does this tile overlap the crop window? + tl, tt = i * 256, j * 256 + overlaps = tl < left + size and tl + 256 > left and tt < top + size and tt + 256 > top + if overlaps and is_placeholder(data): + ph = True + mosaic.paste(Image.open(io.BytesIO(data)).convert("RGB"), (tl, tt)) + img = mosaic.crop((left, top, left + size, top + size)) + info = metrics(img) + info["placeholder"] = ph + return img, info + + +def metrics(img): + a = np.asarray(img).astype(np.float32) + lum = a @ np.array([0.299, 0.587, 0.114], np.float32) + small = np.asarray(img.convert("L").resize((128, 128), Image.BILINEAR)).astype(np.float32) + gx = np.abs(np.diff(lum, axis=1)).mean() + gy = np.abs(np.diff(lum, axis=0)).mean() + # coarse-scale structure: std of 32px block means (catches flat ocean/cloud/ice) + blocks = lum.reshape(16, 32, 16, 32).mean(axis=(1, 3)) + sat = (a.max(axis=2) - a.min(axis=2)).mean() + return { + "std": float(lum.std()), + "grad": float((gx + gy) / 2), + "block_std": float(blocks.std()), + "small_std": float(small.std()), + "sat": float(sat), + "mean": float(lum.mean()), + } + + +def quality_ok(info): + if info["placeholder"]: + return False, "placeholder" + if info["std"] < 12: + return False, f"low variance (std {info['std']:.1f})" + if info["grad"] < 1.3: + return False, f"featureless (grad {info['grad']:.1f})" + if info["mean"] > 225 and info["std"] < 25: + return False, "cloud/snow white-out" + if info["mean"] < 22: + return False, "too dark" + return True, "" diff --git a/intro-sequence/tools/live/build_transit.py b/intro-sequence/tools/live/build_transit.py new file mode 100644 index 000000000..b23111472 --- /dev/null +++ b/intro-sequence/tools/live/build_transit.py @@ -0,0 +1,82 @@ +"""MBTA static GTFS -> city/boston/transit.json (subway, Mattapan, commuter rail, bus). +usage: python3 build_transit.py """ +import csv, json, math, os, sys +from collections import defaultdict +sys.path.insert(0, os.path.dirname(__file__)) +from common import SCENES, enu +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +G = sys.argv[1] +H = SCENES['boston'][2]; M = H + 1000 + +def rdp(pts, eps): + if len(pts) < 3: return pts + keep = [False] * len(pts); keep[0] = keep[-1] = True + stack = [(0, len(pts) - 1)] + while stack: + a, b = stack.pop() + ax, ay = pts[a]; bx, by = pts[b]; dx, dy = bx - ax, by - ay; L = math.hypot(dx, dy) + best, bi = -1, -1 + for i in range(a + 1, b): + px, py = pts[i] + d = abs(dy * (px - ax) - dx * (py - ay)) / L if L else math.hypot(px - ax, py - ay) + if d > best: best, bi = d, i + if best > eps: + keep[bi] = True; stack += [(a, bi), (bi, b)] + return [p for p, k in zip(pts, keep) if k] + +def length(p): return sum(math.hypot(p[i+1][0]-p[i][0], p[i+1][1]-p[i][1]) for i in range(len(p)-1)) + +routes = {} +for r in csv.DictReader(open(os.path.join(G, 'routes.txt'), encoding='utf-8-sig')): + t = int(r['route_type']) + if t not in (0, 1, 2, 3): continue + if 'Replacement' in r['route_desc'] or r['route_id'].startswith('Shuttle'): continue + name = r['route_short_name'] or r['route_long_name'] + if t == 3 and r['route_short_name'] and r['route_long_name']: + name = f"{r['route_short_name']} {r['route_long_name']}" + routes[r['route_id']] = {'name': name, 'color': '#' + (r['route_color'] or '888888').upper(), 'type': t} + +shape_rd = {} +for t in csv.DictReader(open(os.path.join(G, 'trips.txt'), encoding='utf-8-sig')): + if t['route_id'] in routes and t['shape_id']: + shape_rd.setdefault(t['shape_id'], (t['route_id'], t['direction_id'])) + +shapes = defaultdict(list) +for s in csv.DictReader(open(os.path.join(G, 'shapes.txt'), encoding='utf-8-sig')): + if s['shape_id'] in shape_rd: + shapes[s['shape_id']].append((int(s['shape_pt_sequence']), float(s['shape_pt_lat']), float(s['shape_pt_lon']))) + +best = {} # (route, dir) -> (len_in_square, segments) +for sid, pts in shapes.items(): + pts.sort() + xy = [enu('boston', la, lo) for _, la, lo in pts] + if not any(abs(x) <= H and abs(y) <= H for x, y in xy): continue # must pass within the square + segs, cur = [], [] + for i, (x, y) in enumerate(xy): + if abs(x) <= M and abs(y) <= M: + if not cur and i > 0: cur.append(xy[i-1]) # carry edge point + cur.append((x, y)) + elif cur: + cur.append((x, y)); segs.append(cur); cur = [] + if cur: segs.append(cur) + segs = [rdp(s, 2.0) for s in segs if len(s) > 1] + L = sum(length(s) for s in segs) + key = shape_rd[sid] + if key not in best or L > best[key][0]: best[key] = (L, segs) + +out_shapes, seen = [], set() +for (rid, d), (L, segs) in sorted(best.items()): + for s in segs: + flat = tuple(round(v, 1) for p in s for v in p) + if (rid, flat) in seen: continue + seen.add((rid, flat)) + out_shapes.append([rid] + list(flat)) +used = {s[0] for s in out_shapes} +out = {'routes': {k: v for k, v in routes.items() if k in used}, 'shapes': out_shapes, + 'source': 'MBTA GTFS ' + open(os.path.join(G, 'feed_info.txt')).read().splitlines()[1].split(',"')[1].split('"')[0]} +json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'transit.json'), 'w'), separators=(',', ':')) +bytype = defaultdict(int) +for k in used: bytype[routes[k]['type']] += 1 +print('routes', len(used), dict(bytype), 'shapes', len(out_shapes), 'pts', sum((len(s)-1)//2 for s in out_shapes)) +print('rail routes:', sorted(k for k in used if routes[k]['type'] != 3)) +print('missing requested:', [k for k in ['Red','Orange','Blue','Green-B','Green-C','Green-D','Green-E','Mattapan'] if k not in used]) diff --git a/intro-sequence/tools/live/common.py b/intro-sequence/tools/live/common.py new file mode 100644 index 000000000..c46448e3d --- /dev/null +++ b/intro-sequence/tools/live/common.py @@ -0,0 +1,15 @@ +import math +SCENES = { + 'nyc': (40.7400, -73.9900, 3500), + 'boston': (42.3555, -71.0605, 3200), + 'lax': (33.9425, -118.4081, 3500), + 'bragg': (35.1580, -79.0090, 6000), +} +def enu(slug, lat, lon): + lat0, lon0, _ = SCENES[slug] + x = (lon - lon0) * math.cos(math.radians(lat0)) * 111320 + y = (lat - lat0) * 110540 + return round(x, 1), round(y, 1) +def inside(slug, x, y, margin=0.0): + h = SCENES[slug][2] + margin + return abs(x) <= h and abs(y) <= h diff --git a/intro-sequence/tools/live/detect.py b/intro-sequence/tools/live/detect.py new file mode 100644 index 000000000..c4051641e --- /dev/null +++ b/intro-sequence/tools/live/detect.py @@ -0,0 +1,76 @@ +"""YOLOv8n (ONNX, CPU) over cctv/cam_*.jpg and cctv/nyc/*.jpg -> cctv/detections.json. +Model auto-downloads to tools/live/models/yolov8n.onnx (git-ignored).""" +import glob, json, os, sys, time, requests +import numpy as np, onnxruntime as ort +from PIL import Image, ImageDraw +from collections import Counter +HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.abspath(os.path.join(HERE, '..', '..')) +MODEL = os.path.join(HERE, 'models', 'yolov8n.onnx') +MODEL_URL = 'https://huggingface.co/Kalray/yolov8/resolve/main/yolov8n.onnx' +KEEP = {0: 'person', 1: 'bicycle', 2: 'car', 3: 'motorcycle', 5: 'bus', 7: 'truck', 9: 'traffic light', 11: 'stop sign'} +CONF, IOU, S = 0.16, 0.45, 640 +if not os.path.exists(MODEL): + os.makedirs(os.path.dirname(MODEL), exist_ok=True) + open(MODEL, 'wb').write(requests.get(MODEL_URL, timeout=120).content) +sess = ort.InferenceSession(MODEL, providers=['CPUExecutionProvider']) +inp = sess.get_inputs()[0].name + +def nms(b, s, thr): + order = s.argsort()[::-1]; keep = [] + while order.size: + i = order[0]; keep.append(i) + xx0 = np.maximum(b[i, 0], b[order[1:], 0]); yy0 = np.maximum(b[i, 1], b[order[1:], 1]) + xx1 = np.minimum(b[i, 2], b[order[1:], 2]); yy1 = np.minimum(b[i, 3], b[order[1:], 3]) + inter = np.clip(xx1 - xx0, 0, None) * np.clip(yy1 - yy0, 0, None) + a = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1]) + iou = inter / (a[i] + a[order[1:]] - inter + 1e-9) + order = order[1:][iou <= thr] + return keep + +def detect(path): + im = Image.open(path).convert('RGB'); W, H = im.size + r = min(S / W, S / H); nw, nh = round(W * r), round(H * r); px, py = (S - nw) // 2, (S - nh) // 2 + canvas = Image.new('RGB', (S, S), (114, 114, 114)); canvas.paste(im.resize((nw, nh), Image.BILINEAR), (px, py)) + x = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0 + p = sess.run(None, {inp: x})[0][0].T # 8400 x 84 + sc = p[:, 4:]; cls = sc.argmax(1); conf = sc.max(1) + m = (conf >= CONF) & np.isin(cls, list(KEEP)) + p, cls, conf = p[m], cls[m], conf[m] + if not len(p): return [] + b = np.stack([p[:, 0] - p[:, 2] / 2, p[:, 1] - p[:, 3] / 2, p[:, 0] + p[:, 2] / 2, p[:, 1] + p[:, 3] / 2], 1) + out = [] + for c in np.unique(cls): # class-wise NMS + idx = np.where(cls == c)[0] + for k in nms(b[idx], conf[idx], IOU): + j = idx[k]; x0, y0, x1, y1 = b[j] + x0 = min(max((x0 - px) / r / W, 0), 1); x1 = min(max((x1 - px) / r / W, 0), 1) + y0 = min(max((y0 - py) / r / H, 0), 1); y1 = min(max((y1 - py) / r / H, 0), 1) + if x1 - x0 > 0 and y1 - y0 > 0: + out.append([round(float(x0), 4), round(float(y0), 4), round(float(x1), 4), round(float(y1), 4), round(float(conf[j]), 3), KEEP[int(c)]]) + return sorted(out, key=lambda d: -d[4]) + +COL = {'person': (255, 60, 60), 'car': (60, 200, 255), 'truck': (255, 180, 0), 'bus': (255, 0, 255), 'bicycle': (0, 255, 120), + 'motorcycle': (180, 255, 0), 'traffic light': (255, 255, 255), 'stop sign': (255, 0, 0)} +def render(path, dets, dst): + im = Image.open(path).convert('RGB'); W, H = im.size; d = ImageDraw.Draw(im) + for x0, y0, x1, y1, c, n in dets: + d.rectangle([x0 * W, y0 * H, x1 * W, y1 * H], outline=COL[n], width=2) + d.text((x0 * W + 2, max(0, y0 * H - 11)), f'{n} {c:.2f}', fill=COL[n]) + im.save(dst, quality=90) + +if __name__ == '__main__': + files = sorted(glob.glob(os.path.join(ROOT, 'cctv', 'cam_*.jpg'))) + sorted(glob.glob(os.path.join(ROOT, 'cctv', 'nyc', '*.jpg'))) + res, tot, t0 = {}, Counter(), time.time() + for f in files: + rel = os.path.relpath(f, os.path.join(ROOT, 'cctv')) + res[rel] = detect(f); tot.update(d[5] for d in res[rel]) + dt = time.time() - t0 + json.dump(res, open(os.path.join(ROOT, 'cctv', 'detections.json'), 'w'), separators=(',', ':')) + print('images', len(files), 'secs', round(dt, 1), 'ms/img', round(1000 * dt / max(1, len(files)))) + print('totals', dict(tot.most_common()), 'all', sum(tot.values())) + print('nyc totals', dict(Counter(d[5] for k, v in res.items() if k.startswith('nyc/') for d in v))) + picks = sys.argv[1:] or [max((k for k in res if k.startswith('nyc/')), key=lambda k: len(res[k])), + max((k for k in res if not k.startswith('nyc/')), key=lambda k: len(res[k])), + max(res, key=lambda k: sum(d[5] == 'person' for d in res[k]))] + for i, k in enumerate(picks, 1): + render(os.path.join(ROOT, 'cctv', k), res[k], os.path.join(HERE, f'det_check_{i}.jpg')); print('render', i, k, len(res[k])) diff --git a/intro-sequence/tools/live/fetch_adsb.py b/intro-sequence/tools/live/fetch_adsb.py new file mode 100644 index 000000000..4eee40870 --- /dev/null +++ b/intro-sequence/tools/live/fetch_adsb.py @@ -0,0 +1,72 @@ +"""Poll live ADS-B (adsb.lol, fallback adsb.fi) around each scene 3x, 20 s apart. +Writes city//air.json with latest sample per aircraft + short track history.""" +import json, os, sys, time, requests +sys.path.insert(0, os.path.dirname(__file__)) +from common import SCENES, enu +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +RAD = {'nyc': 25, 'lax': 25, 'bragg': 25, 'boston': 20} +HELI_TYPES = {'EC35','EC30','EC45','EC55','EC20','EC25','EC75','EC13','EC40','AS50','AS55','AS65','AS32','AS3B','H125','H130','H135','H145','H160','H175','B06','B06T','B407','B412','B427','B429','B430','B47G','B505','B212','B222','B230','B105','BK17','R22','R44','R66','S76','S92','S61','S64','S70','H60','UH60','MH60','HH60','H47','CH47','H53','A109','A119','A139','A169','A189','AW09','MI8','MI17','UH1','UH1Y','AH1','AH64','H64','V22','MV22','CABR','G2CA','EN28','EN48','H269','H500','MD52','MD60','NH90','LYNX','PUMA','GAZL','EH10','H1','B505'} +H = {'User-Agent': 'gods-eye-view-intro/1.0 (film research)'} +def fetch(slug): + lat, lon, _ = SCENES[slug]; nm = RAD[slug] + for url, key in ((f'https://api.adsb.lol/v2/point/{lat}/{lon}/{nm}', 'ac'), + (f'https://opendata.adsb.fi/api/v2/lat/{lat}/lon/{lon}/dist/{nm}', 'aircraft')): + try: + r = requests.get(url, headers=H, timeout=20); r.raise_for_status(); j = r.json() + ac = j.get('ac') or j.get('aircraft') or [] + now = j.get('now') or time.time() * 1000 + return url, now / 1000 if now > 1e11 else now, ac + except Exception as e: + print(slug, 'fail', url, e, file=sys.stderr) + return None, time.time(), [] +def num(v): + try: return float(v) + except Exception: return None +def conv(slug, a): + lat, lon = num(a.get('lat')), num(a.get('lon')) + if lat is None or lon is None: + lp = a.get('lastPosition') or {} + lat, lon = num(lp.get('lat')), num(lp.get('lon')) + if lat is None: return None + x, y = enu(slug, lat, lon) + ab = a.get('alt_baro') + if ab == 'ground': alt = 0.0 + else: + v = num(ab) + if v is None: v = num(a.get('alt_geom')) + alt = round(v * 0.3048, 1) if v is not None else None + trk = num(a.get('track')); trk = num(a.get('true_heading')) if trk is None else trk + gs = num(a.get('gs')); gs = round(gs * 0.514444, 1) if gs is not None else None + vr = num(a.get('baro_rate')); vr = num(a.get('geom_rate')) if vr is None else vr + vr = round(vr * 0.00508, 2) if vr is not None else None + return [x, y, alt, trk, gs, vr, (a.get('flight') or '').strip(), a.get('t') or '', a.get('category') or ''] +def main(): + polls = {s: [] for s in SCENES} + for i in range(3): + t0 = time.time() + for s in SCENES: + url, t, ac = fetch(s); polls[s].append((url, t, ac)); print(i, s, len(ac), url, flush=True) + if i < 2: time.sleep(max(0, 20 - (time.time() - t0))) + summary = {} + for s in SCENES: + latest, tracks, src = {}, {}, set() + tlast = 0 + for url, t, ac in polls[s]: + if url: src.add(url.split('/')[2]) + tlast = max(tlast, t) + for a in ac: + hx = a.get('hex'); row = conv(s, a) + if not hx or row is None: continue + if (num(a.get('seen_pos')) or 0) > 60: continue + latest[hx] = row + tr = tracks.setdefault(hx, []) + p = [row[0], row[1], row[2]] + if not tr or tr[-1][:2] != p[:2]: tr.append(p) + heli = [h for h, r in latest.items() if r[8] == 'A7' or r[7] in HELI_TYPES] + out = {'t': int(tlast), 'source': sorted(src), 'radius_nm': RAD[s], 'hex': list(latest.keys()), + 'a': list(latest.values()), 'tracks': {h: v for h, v in tracks.items() if len(v) > 1}} + json.dump(out, open(os.path.join(ROOT, 'city', s, 'air.json'), 'w'), separators=(',', ':')) + summary[s] = {'aircraft': len(latest), 'with_track': len(out['tracks']), 'heli': len(heli), + 'heli_list': [[latest[h][6], latest[h][7], latest[h][8], latest[h][2]] for h in heli]} + print(json.dumps(summary, indent=1)) +if __name__ == '__main__': main() diff --git a/intro-sequence/tools/live/fetch_mbta_vehicles.py b/intro-sequence/tools/live/fetch_mbta_vehicles.py new file mode 100644 index 000000000..a980f0549 --- /dev/null +++ b/intro-sequence/tools/live/fetch_mbta_vehicles.py @@ -0,0 +1,21 @@ +"""Live MBTA vehicle positions -> city/boston/vehicles.json (square + 1 km).""" +import json, os, sys, requests +from collections import Counter +sys.path.insert(0, os.path.dirname(__file__)) +from common import enu, inside +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +j = requests.get('https://cdn.mbta.com/realtime/VehiclePositions_enhanced.json', timeout=30).json() +rows, total = [], 0 +for e in j['entity']: + v = e.get('vehicle') or {}; p = v.get('position') or {} + if 'latitude' not in p: continue + total += 1 + x, y = enu('boston', p['latitude'], p['longitude']) + if not inside('boston', x, y, 1000): continue + rid = (v.get('trip') or {}).get('route_id', '') + lab = (v.get('vehicle') or {}).get('label') or (v.get('vehicle') or {}).get('id', '') + sp = p.get('speed'); rows.append([x, y, p.get('bearing'), round(sp, 2) if sp is not None else None, rid, lab]) +out = {'t': int(j['header']['timestamp']), 'source': 'cdn.mbta.com VehiclePositions_enhanced.json', 'v': rows} +json.dump(out, open(os.path.join(ROOT, 'city', 'boston', 'vehicles.json'), 'w'), separators=(',', ':')) +c = Counter(r[4].split('-')[0] if r[4].startswith(('Green', 'CR')) else ('bus' if r[4][:1].isdigit() or r[4][:2] in ('SL','CT') else r[4]) for r in rows) +print('t', out['t'], 'feed total', total, 'in square+1km', len(rows), dict(c)) diff --git a/intro-sequence/tools/live/fetch_nyc_cams.py b/intro-sequence/tools/live/fetch_nyc_cams.py new file mode 100644 index 000000000..abcb29eaa --- /dev/null +++ b/intro-sequence/tools/live/fetch_nyc_cams.py @@ -0,0 +1,53 @@ +"""NYC DOT TMC cameras -> city/nyc/cameras.json + current stills for ~24 nearest cams.""" +import hashlib, io, json, os, sys, time, requests +import numpy as np +from PIL import Image, ImageOps +from collections import Counter +sys.path.insert(0, os.path.dirname(__file__)) +from common import enu, inside +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) +OUT = os.path.join(ROOT, 'cctv', 'nyc'); DBG = os.path.join(os.path.dirname(__file__), 'nyc_rejects') +os.makedirs(OUT, exist_ok=True); os.makedirs(DBG, exist_ok=True) +H = {'User-Agent': 'gods-eye-view-intro/1.0'} +cat = requests.get('https://webcams.nyctmc.org/api/cameras', headers=H, timeout=30).json() +cams = [] +for c in cat: + if str(c.get('isOnline')).lower() != 'true': continue + try: lat, lon = float(c['latitude']), float(c['longitude']) + except Exception: continue + x, y = enu('nyc', lat, lon) + if inside('nyc', x, y): cams.append((x, y, c['id'], c['name'].strip(), lat, lon)) +json.dump([[x, y, i, n] for x, y, i, n, _, _ in cams], open(os.path.join(ROOT, 'city', 'nyc', 'cameras.json'), 'w'), separators=(',', ':')) +print('catalog', len(cat), 'online in square', len(cams)) +cams.sort(key=lambda c: c[0]**2 + c[1]**2) +for f in os.listdir(OUT): + if f.startswith('cam_') and f.endswith('.jpg'): os.remove(os.path.join(OUT, f)) +idx, rejects, hashes, raw_imgs = [], Counter(), Counter(), [] +fetched_utc = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) +for x, y, cid, name, lat, lon in cams[:80]: + if len(raw_imgs) >= 40: break + try: + r = requests.get(f'https://webcams.nyctmc.org/api/cameras/{cid}/image', headers=H, timeout=20) + r.raise_for_status(); im = Image.open(io.BytesIO(r.content)).convert('RGB') + except Exception as e: + rejects['error'] += 1; continue + h = hashlib.md5(r.content).hexdigest(); hashes[h] += 1 + g = np.asarray(im.convert('L'), dtype=np.float32) + raw_imgs.append(dict(x=x, y=y, id=cid, name=name, lat=lat, lon=lon, im=im, h=h, mean=float(g.mean()), std=float(g.std()), size=im.size, n=len(r.content))) +n = 0 +for d in raw_imgs: + reason = None + if hashes[d['h']] > 1: reason = 'duplicate/placeholder' + elif d['mean'] < 14: reason = 'black' + elif d['std'] < 10: reason = 'flat' + elif d['size'][0] < 200: reason = 'tiny' + if reason: + rejects[reason] += 1; d['im'].save(os.path.join(DBG, f"{reason.split('/')[0]}_{d['id'][:8]}.jpg")); continue + if n >= 24: break + n += 1; fn = f'cam_{n:03d}.jpg' + ImageOps.fit(d['im'], (640, 360), Image.LANCZOS).save(os.path.join(OUT, fn), quality=88) + idx.append({'file': fn, 'id': d['id'], 'name': d['name'], 'x': d['x'], 'y': d['y'], 'lat': d['lat'], 'lon': d['lon'], + 'orig_size': list(d['size']), 'fetched_utc': fetched_utc, 'source': 'NYC DOT TMC webcams.nyctmc.org'}) + print(fn, d['name'], d['size'], round(d['mean']), round(d['std']), round((d['x']**2+d['y']**2)**.5)) +json.dump(idx, open(os.path.join(OUT, 'index.json'), 'w'), indent=1) +print('saved', n, 'rejects', dict(rejects), 'tried', len(raw_imgs)) diff --git a/intro-sequence/tools/render-all.sh b/intro-sequence/tools/render-all.sh index c63a67fbc..69b5b201a 100755 --- a/intro-sequence/tools/render-all.sh +++ b/intro-sequence/tools/render-all.sh @@ -3,12 +3,13 @@ # tools/render-all.sh 1920 1080 16x9 set -u W=$1; H=$2; TAG=$3 -CH=240; TOTAL=2160 -mkdir -p out/chunks_$TAG +CH=240; TOTAL=${4:-2850} +mkdir -p out/${PREFIX:-}chunks_$TAG for ((s=0; s TOTAL ? TOTAL : e )) + node tools/render.mjs --port ${PORT:-8766} --w $W --h $H --from $s --to $e --video "$f.tmp.mp4" --crf 14 --preset slow && mv "$f.tmp.mp4" "$f" done -ls out/chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/chunks_$TAG/list.txt -ffmpeg -y -loglevel error -f concat -safe 0 -i out/chunks_$TAG/list.txt -c copy out/video_$TAG.mp4 && echo "CONCAT OK $TAG" +ls out/${PREFIX:-}chunks_$TAG/c_*.mp4 | sed "s|^|file '$PWD/|; s|$|'|" > out/${PREFIX:-}chunks_$TAG/list.txt +ffmpeg -y -loglevel error -f concat -safe 0 -i out/${PREFIX:-}chunks_$TAG/list.txt -c copy out/${PREFIX:-}video_$TAG.mp4 && echo "CONCAT OK $TAG"