Diff -- /var/www/vhosts/3dshawn.com/admin.3dshawn.com/assets/javascript/globe.js
Diff
/var/www/vhosts/3dshawn.com/admin.3dshawn.com/assets/javascript/globe.js
added on local at 2026-07-06 17:40:44
Added
+0
lines
Removed
-0
lines
Context
1481
unchanged
Blobs
from e6a15088ae4b
to e6a15088ae4b
to e6a15088ae4b
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | /*====================================================================== |
| 2 | 2 | * TaskForge - Interactive 3D globe (vanilla JS, no deps) |
| 3 | 3 | * |
| 4 | 4 | * Renders a draggable, zoomable orthographic globe into the host |
| 5 | 5 | * <div data-globe>. Reads country dot data from window.GLOBE_DATA |
| 6 | 6 | * (set by the host page via an inline <script>) so the same widget |
| 7 | 7 | * used by the admin Traffic Statistics page. |
| 8 | 8 | * |
| 9 | 9 | * Interactions: |
| 10 | 10 | * - Mouse / touch drag : rotate (lat0, lng0) |
| 11 | 11 | * - Mouse wheel / pinch: zoom (sphere radius) |
| 12 | 12 | * - Click a country dot: navigate to host.dataset.drillHref + ?country=XX |
| 13 | 13 | * - Hover a country dot: in-canvas tooltip with the visitor count |
| 14 | 14 | * - Idle for 3s : slow auto-rotation around the longitude axis |
| 15 | 15 | * |
| 16 | 16 | * Country dots: { code, name, lat, lng, count } |
| 17 | 17 | * Continent shapes: compact lat/lng polygons embedded below. Each |
| 18 | 18 | * polygon edge that crosses the visible horizon is clipped via binary |
| 19 | 19 | * search so continents fade off cleanly at the edge instead of |
| 20 | 20 | * chunking out vertex-by-vertex. |
| 21 | 21 | * |
| 22 | 22 | * Render is decoupled from input via requestAnimationFrame. Input |
| 23 | 23 | * handlers only set state + a dirty flag; the frame loop reads dirty |
| 24 | 24 | * and re-renders. This keeps drag motion smooth even when pointer |
| 25 | 25 | * events fire faster than the browser can paint. |
| 26 | 26 | *====================================================================*/ |
| 27 | 27 | (function () { |
| 28 | 28 | 'use strict'; |
| 29 | 29 | |
| 30 | 30 | // ---- Schematic continent outlines (lat/lng polygons) ---------------- |
| 31 | 31 | // Each polygon traces a major landmass; coastlines are extended out |
| 32 | 32 | // to their real extents (e.g. Somalia at ~51E, the Korean peninsula, |
| 33 | 33 | // Kamchatka, etc.) so the visible portion doesn't appear to terminate |
| 34 | 34 | // before the actual horizon as you rotate. |
| 35 | 35 | var CONTINENTS = [ |
| 36 | 36 | // North America (Alaska -> Central America) |
| 37 | 37 | [[71,-156],[70,-141],[69,-128],[66,-121],[60,-115],[55,-110],[52,-100], |
| 38 | 38 | [49,-95],[49,-89],[49,-83],[47,-80],[45,-77],[44,-66],[44,-60],[41,-65], |
| 39 | 39 | [41,-70],[37,-76],[32,-79],[30,-81],[27,-80],[25,-81],[25,-83],[22,-85], |
| 40 | 40 | [21,-87],[17,-89],[15,-87],[13,-88],[14,-92],[16,-94],[20,-97],[22,-100], |
| 41 | 41 | [22,-105],[23,-110],[26,-112],[31,-117],[34,-120],[38,-123],[44,-124], |
| 42 | 42 | [48,-124],[52,-128],[55,-130],[58,-134],[60,-140],[63,-145],[66,-152], |
| 43 | 43 | [68,-160],[69,-166],[71,-162],[71,-156]], |
| 44 | 44 | |
| 45 | 45 | // Greenland |
| 46 | 46 | [[83,-32],[83,-25],[81,-15],[78,-19],[75,-21],[73,-22],[69,-22],[64,-40], |
| 47 | 47 | [60,-43],[60,-48],[64,-52],[68,-54],[72,-55],[76,-65],[78,-68],[81,-63], |
| 48 | 48 | [83,-55],[83,-50],[83,-40],[83,-32]], |
| 49 | 49 | |
| 50 | 50 | // South America |
| 51 | 51 | [[12,-72],[12,-68],[11,-65],[8,-60],[6,-58],[3,-50],[-1,-50],[-3,-44], |
| 52 | 52 | [-6,-35],[-10,-36],[-13,-38],[-15,-38],[-18,-39],[-22,-40],[-25,-46], |
| 53 | 53 | [-28,-48],[-30,-50],[-34,-52],[-37,-56],[-39,-58],[-43,-62],[-46,-66], |
| 54 | 54 | [-50,-69],[-52,-70],[-55,-68],[-54,-70],[-50,-73],[-46,-74],[-43,-74], |
| 55 | 55 | [-38,-73],[-35,-72],[-30,-71],[-26,-70],[-22,-70],[-18,-70],[-15,-75], |
| 56 | 56 | [-10,-78],[-7,-79],[-5,-81],[0,-80],[3,-78],[5,-77],[8,-77],[10,-76], |
| 57 | 57 | [12,-72]], |
| 58 | 58 | |
| 59 | 59 | // Europe (with Iberia, Italy, Scandinavia, eastern reach into Russia) |
| 60 | 60 | [[71,28],[71,40],[68,15],[64,5],[60,5],[58,5],[55,7],[55,12],[55,15], |
| 61 | 61 | [54,8],[51,2],[49,-5],[46,-2],[44,-9],[40,-9],[37,-9],[36,-5],[36,0], |
| 62 | 62 | [38,8],[40,15],[40,18],[40,22],[37,27],[36,27],[40,29],[45,30],[50,35], |
| 63 | 63 | [55,40],[55,30],[58,28],[60,28],[65,25],[68,22],[70,25],[71,28]], |
| 64 | 64 | |
| 65 | 65 | // Africa (extended to Horn of Africa ~51E) |
| 66 | 66 | [[36,-6],[36,3],[35,11],[33,22],[31,32],[29,34],[25,35],[22,36],[18,38], |
| 67 | 67 | [12,43],[11,45],[10,49],[8,50],[5,46],[3,42],[0,42],[-2,41],[-5,40], |
| 68 | 68 | [-8,40],[-12,40],[-16,40],[-22,35],[-26,32],[-28,32],[-30,30],[-32,28], |
| 69 | 69 | [-34,28],[-34,22],[-34,18],[-31,17],[-28,16],[-23,14],[-20,12],[-15,12], |
| 70 | 70 | [-12,13],[-8,12],[-5,9],[-2,9],[2,9],[4,9],[6,3],[5,-1],[5,-3],[6,-8], |
| 71 | 71 | [8,-13],[11,-15],[14,-17],[17,-16],[20,-17],[23,-16],[28,-13],[31,-9], |
| 72 | 72 | [34,-7],[36,-6]], |
| 73 | 73 | |
| 74 | 74 | // Asia (extends to Kamchatka, Korea, SE Asia) |
| 75 | 75 | [[72,55],[75,75],[78,90],[76,105],[74,120],[72,128],[70,140],[67,155], |
| 76 | 76 | [67,170],[62,177],[58,172],[55,160],[55,155],[51,156],[48,142],[46,140], |
| 77 | 77 | [45,135],[41,132],[38,131],[36,131],[35,129],[34,128],[34,126],[34,124], |
| 78 | 78 | [38,121],[39,122],[37,122],[35,118],[33,121],[30,121],[28,121],[24,118], |
| 79 | 79 | [22,113],[21,110],[19,108],[17,108],[14,109],[10,107],[8,106],[5,103], |
| 80 | 80 | [2,103],[5,101],[7,99],[10,99],[12,98],[16,95],[20,93],[22,90],[22,86], |
| 81 | 81 | [19,85],[19,82],[15,80],[12,77],[9,77],[8,80],[11,75],[15,73],[19,72], |
| 82 | 82 | [22,69],[23,68],[25,62],[26,62],[27,56],[24,55],[24,52],[22,46],[20,42], |
| 83 | 83 | [15,44],[13,44],[15,52],[18,55],[12,55],[20,58],[24,60],[30,57],[35,53], |
| 84 | 84 | [38,48],[40,48],[42,48],[44,48],[46,52],[48,55],[51,52],[52,50],[55,45], |
| 85 | 85 | [58,40],[60,42],[62,45],[65,42],[68,45],[70,50],[72,55]], |
| 86 | 86 | |
| 87 | 87 | // Australia |
| 88 | 88 | [[-11,142],[-11,138],[-13,135],[-15,130],[-18,123],[-20,118],[-22,114], |
| 89 | 89 | [-26,114],[-30,115],[-31,115],[-34,118],[-35,121],[-35,123],[-35,127], |
| 90 | 90 | [-34,133],[-37,140],[-38,143],[-38,145],[-39,147],[-37,149],[-34,151], |
| 91 | 91 | [-32,153],[-28,153],[-25,153],[-22,150],[-19,147],[-15,145],[-13,143], |
| 92 | 92 | [-11,142]], |
| 93 | 93 | |
| 94 | 94 | // Antarctica edge |
| 95 | 95 | [[-66,-180],[-66,-150],[-66,-120],[-70,-100],[-70,-90],[-72,-70],[-72,-60], |
| 96 | 96 | [-75,-45],[-75,-30],[-72,-10],[-72,0],[-70,15],[-70,30],[-72,50],[-72,60], |
| 97 | 97 | [-75,75],[-75,90],[-74,105],[-72,120],[-70,135],[-68,150],[-66,165], |
| 98 | 98 | [-66,180]], |
| 99 | 99 | |
| 100 | 100 | // British Isles |
| 101 | 101 | [[58,-7],[60,-3],[58,-2],[57,-2],[55,-2],[55,-6],[53,-5],[51,-4],[50,-5], |
| 102 | 102 | [50,-2],[51,1],[53,1],[54,-1],[57,-3],[58,-7]], |
| 103 | 103 | |
| 104 | 104 | // Iceland |
| 105 | 105 | [[66,-24],[67,-19],[66,-14],[64,-14],[63,-19],[63,-22],[64,-24],[66,-24]], |
| 106 | 106 | |
| 107 | 107 | // Japan |
| 108 | 108 | [[45,142],[44,143],[42,142],[40,141],[37,141],[35,140],[34,137],[33,135], |
| 109 | 109 | [33,131],[32,129],[33,130],[34,132],[36,135],[37,137],[39,140],[42,141], |
| 110 | 110 | [45,142]], |
| 111 | 111 | |
| 112 | 112 | // Indonesia |
| 113 | 113 | [[5,95],[3,98],[1,100],[-1,102],[-3,103],[-5,105],[-5,107],[-7,109], |
| 114 | 114 | [-8,113],[-9,116],[-8,121],[-6,124],[-5,122],[-3,124],[-2,124],[0,122], |
| 115 | 115 | [1,121],[2,118],[2,116],[3,113],[4,108],[5,99],[5,95]], |
| 116 | 116 | |
| 117 | 117 | // Madagascar |
| 118 | 118 | [[-12,49],[-15,50],[-18,49],[-20,46],[-22,44],[-24,44],[-25,46],[-24,48], |
| 119 | 119 | [-22,49],[-19,49],[-15,50],[-12,49]], |
| 120 | 120 | |
| 121 | 121 | // New Zealand |
| 122 | 122 | [[-34,173],[-37,175],[-39,178],[-41,174],[-43,172],[-46,167],[-47,170], |
| 123 | 123 | [-44,174],[-41,177],[-39,178],[-34,173]] |
| 124 | 124 | ]; |
| 125 | 125 | |
| 126 | 126 | var D2R = Math.PI / 180; |
| 127 | 127 | |
| 128 | 128 | // Subdivide every edge of a polygon into smaller steps. The raw |
| 129 | 129 | // CONTINENTS data only has a few dozen vertices per landmass, which |
| 130 | 130 | // means a straight lat/lng line between two faraway points doesn't |
| 131 | 131 | // bow the way a real coastline does -- so as you rotate the globe, |
| 132 | 132 | // the visible portion appears to terminate well inside the actual |
| 133 | 133 | // horizon. Densifying once at init costs a few KB of memory and |
| 134 | 134 | // costs nothing per frame. |
| 135 | 135 | function densify(points, maxStep) { |
| 136 | 136 | var out = [points[0]]; |
| 137 | 137 | for (var i = 1; i < points.length; i++) { |
| 138 | 138 | var p1 = points[i - 1], p2 = points[i]; |
| 139 | 139 | var dLat = p2[0] - p1[0]; |
| 140 | 140 | var dLng = p2[1] - p1[1]; |
| 141 | 141 | // Distance in lat/lng degrees. Good enough as a step trigger; |
| 142 | 142 | // we don't need true great-circle distance here. |
| 143 | 143 | var dist = Math.sqrt(dLat * dLat + dLng * dLng); |
| 144 | 144 | var n = Math.max(1, Math.ceil(dist / maxStep)); |
| 145 | 145 | for (var j = 1; j <= n; j++) { |
| 146 | 146 | var t = j / n; |
| 147 | 147 | out.push([ p1[0] + dLat * t, p1[1] + dLng * t ]); |
| 148 | 148 | } |
| 149 | 149 | } |
| 150 | 150 | return out; |
| 151 | 151 | } |
| 152 | 152 | |
| 153 | 153 | // Pre-densified continents (one-shot at module load). |
| 154 | 154 | var CONTINENTS_DENSE = []; |
| 155 | 155 | for (var ci = 0; ci < CONTINENTS.length; ci++) { |
| 156 | 156 | CONTINENTS_DENSE.push(densify(CONTINENTS[ci], 2)); |
| 157 | 157 | } |
| 158 | 158 | |
| 159 | 159 | // Major internal land borders (polylines, NOT closed polygons). |
| 160 | 160 | // Coastlines are already drawn by the continent outlines, so these |
| 161 | 161 | // are just the political lines that aren't coastlines. Coarse on |
| 162 | 162 | // purpose -- we want recognizable boundaries, not survey-grade |
| 163 | 163 | // cartography. |
| 164 | 164 | var COUNTRY_BORDERS = [ |
| 165 | 165 | // US / Canada -- 49th parallel + Great Lakes + Maine |
| 166 | 166 | [[49,-123],[49,-117],[49,-110],[49,-104],[49,-100],[49,-97],[49,-95], |
| 167 | 167 | [48.5,-92],[48,-89],[47.5,-89],[47,-88],[46.5,-84],[46,-84],[45.5,-83.5], |
| 168 | 168 | [44,-82.5],[42.5,-82.5],[42,-83],[42,-81],[42.5,-79],[43,-79], |
| 169 | 169 | [43.5,-78],[44,-76],[45,-74.5],[45,-71.5],[45.5,-70],[46,-68], |
| 170 | 170 | [47,-68],[47,-67],[45.5,-67]], |
| 171 | 171 | |
| 172 | 172 | // US / Mexico -- Pacific to the Gulf |
| 173 | 173 | [[32.5,-117.1],[32.7,-114.7],[31.3,-111],[31.3,-108.2],[31.8,-106.5], |
| 174 | 174 | [29.8,-104],[29.5,-101.5],[28.7,-100.5],[26.4,-99.2],[26,-97.4]], |
| 175 | 175 | |
| 176 | 176 | // China / Russia -- Amur River + Mongolia top edge |
| 177 | 177 | [[49,86],[50,90],[52,100],[53,105],[51,110],[50,115],[50,120],[48,125], |
| 178 | 178 | [49,130],[51,134],[53,140]], |
| 179 | 179 | |
| 180 | 180 | // China / India -- Himalayas |
| 181 | 181 | [[35,77],[34,79],[33,80],[32,82],[30,86],[28,88],[27,90],[27,92], |
| 182 | 182 | [29,96]], |
| 183 | 183 | |
| 184 | 184 | // India / Pakistan |
| 185 | 185 | [[35,75],[33,75],[31,75],[29,73],[27,71],[24,69],[24,68]], |
| 186 | 186 | |
| 187 | 187 | // China / Mongolia (south side) |
| 188 | 188 | [[42,90],[42,95],[43,100],[43,105],[42,110],[43,115],[44,118]], |
| 189 | 189 | |
| 190 | 190 | // Brazil -- inland boundary with neighbors (roughly traces W + N edge) |
| 191 | 191 | [[5,-60],[4,-61],[2,-65],[1,-67],[0,-68],[-2,-70],[-4,-70],[-7,-73], |
| 192 | 192 | [-10,-69],[-12,-69],[-15,-60],[-18,-58],[-21,-58],[-23,-58], |
| 193 | 193 | [-27,-55],[-30,-57]], |
| 194 | 194 | |
| 195 | 195 | // Argentina / Chile -- Andes spine |
| 196 | 196 | [[-22,-67],[-25,-68],[-28,-69],[-32,-70],[-36,-71],[-40,-71.5], |
| 197 | 197 | [-45,-72],[-50,-72],[-53,-71]], |
| 198 | 198 | |
| 199 | 199 | // Germany / France / Belgium / Netherlands (rough corridor) |
| 200 | 200 | [[50.5,6.4],[50,6],[49.5,6],[49.2,7],[49,7.6],[48.5,8],[47.6,7.6], |
| 201 | 201 | [47.5,7],[47.5,6.7]], |
| 202 | 202 | |
| 203 | 203 | // ===== Expanded political dividing lines =========================== |
| 204 | 204 | |
| 205 | 205 | // Spain / Portugal |
| 206 | 206 | [[42,-8.5],[41,-7.5],[40,-7],[39,-7.5],[38,-7],[37.2,-7.5]], |
| 207 | 207 | |
| 208 | 208 | // France / Spain (Pyrenees) |
| 209 | 209 | [[43.4,-1.5],[42.9,0],[42.7,1.5],[42.4,2.7],[42.4,3.0]], |
| 210 | 210 | |
| 211 | 211 | // France / Italy / Switzerland (Alps corridor) |
| 212 | 212 | [[45.9,7.0],[45.5,6.9],[44.8,6.9],[44.2,7.4],[43.8,7.5]], |
| 213 | 213 | |
| 214 | 214 | // Italy / Slovenia / Austria |
| 215 | 215 | [[46.5,13.7],[46.4,12.5],[46.5,11.0]], |
| 216 | 216 | |
| 217 | 217 | // Czechia / Poland / Slovakia ridge |
| 218 | 218 | [[50.1,14.3],[50.3,16.0],[50.0,18.5],[49.7,20.4],[49.3,22.5]], |
| 219 | 219 | |
| 220 | 220 | // Hungary / Romania |
| 221 | 221 | [[48.2,22.1],[47.0,22.0],[46.0,21.2],[44.9,22.8]], |
| 222 | 222 | |
| 223 | 223 | // Greece / Bulgaria |
| 224 | 224 | [[41.7,22.9],[41.4,24.0],[41.7,25.3],[41.5,26.1]], |
| 225 | 225 | |
| 226 | 226 | // Norway / Sweden ridge |
| 227 | 227 | [[68.5,17.5],[66.5,15.5],[63.5,12.3],[60.5,12.5],[58.9,11.6]], |
| 228 | 228 | |
| 229 | 229 | // Poland / Belarus / Ukraine corridor |
| 230 | 230 | [[54.4,23.5],[52.7,23.6],[51.5,23.8],[50.3,23.9],[48.5,22.6]], |
| 231 | 231 | |
| 232 | 232 | // Africa: Algeria / Morocco / Mauritania edge |
| 233 | 233 | [[35.2,-2.2],[33.0,-1.8],[30.0,-2.8],[27.7,-8.7],[24.9,-12.0]], |
| 234 | 234 | |
| 235 | 235 | // Africa: Egypt / Sudan / Libya |
| 236 | 236 | [[29.5,25.0],[27.0,25.0],[24.0,25.0],[22.0,25.0],[22.0,32.0]], |
| 237 | 237 | |
| 238 | 238 | // Africa: Kenya / Tanzania / Uganda triangle |
| 239 | 239 | [[-1.0,30.5],[-1.0,34.0],[-1.0,37.0],[-1.7,40.0],[-4.5,39.2]], |
| 240 | 240 | |
| 241 | 241 | // Africa: Nigeria / Cameroon / Niger boundary |
| 242 | 242 | [[13.5,2.3],[13.0,6.5],[13.5,10.0],[13.5,14.2],[10.5,14.5]], |
| 243 | 243 | |
| 244 | 244 | // Africa: South Africa / Botswana / Mozambique |
| 245 | 245 | [[-22.0,29.0],[-22.5,22.0],[-25.5,20.0],[-28.5,20.0],[-29.5,29.0], |
| 246 | 246 | [-26.5,32.0],[-25.5,32.0]], |
| 247 | 247 | |
| 248 | 248 | // Middle East: Saudi Arabia / Iraq / Jordan / Syria |
| 249 | 249 | [[32.5,38.8],[31.5,38.8],[29.8,38.0],[29.0,36.5],[29.5,34.8]], |
| 250 | 250 | |
| 251 | 251 | // Middle East: Saudi Arabia / UAE / Oman |
| 252 | 252 | [[24.0,52.0],[23.0,55.0],[22.5,55.5],[21.0,57.0],[19.0,55.0],[17.0,52.5]], |
| 253 | 253 | |
| 254 | 254 | // Iran / Pakistan / Afghanistan corner |
| 255 | 255 | [[37.0,61.0],[35.5,61.0],[31.5,61.0],[29.0,62.0],[25.5,63.0]], |
| 256 | 256 | |
| 257 | 257 | // Central Asia: Kazakhstan / Uzbekistan / Turkmenistan |
| 258 | 258 | [[42.0,55.0],[42.0,60.0],[42.0,65.0],[41.0,71.0],[40.5,75.0]], |
| 259 | 259 | |
| 260 | 260 | // China / Vietnam / Laos / Cambodia / Thailand |
| 261 | 261 | [[22.5,103.0],[22.5,105.0],[22.0,107.5],[20.0,108.0],[17.0,107.0], |
| 262 | 262 | [14.5,107.0],[12.5,106.5],[10.5,105.0]], |
| 263 | 263 | |
| 264 | 264 | // Indonesia / Malaysia / Singapore arch |
| 265 | 265 | [[3.0,99.5],[2.0,102.0],[1.4,103.8],[1.0,104.5]], |
| 266 | 266 | |
| 267 | 267 | // Russia / Kazakhstan (long northern border) |
| 268 | 268 | [[54.5,55.5],[54.0,62.0],[53.5,68.5],[52.0,75.0],[51.0,82.0]] |
| 269 | 269 | ]; |
| 270 | 270 | |
| 271 | 271 | var COUNTRY_BORDERS_DENSE = []; |
| 272 | 272 | for (var bi = 0; bi < COUNTRY_BORDERS.length; bi++) { |
| 273 | 273 | COUNTRY_BORDERS_DENSE.push(densify(COUNTRY_BORDERS[bi], 2)); |
| 274 | 274 | } |
| 275 | 275 | |
| 276 | 276 | // World country centroids -- used to label countries even when no |
| 277 | 277 | // traffic has come in from them yet. Mirror of the Perl |
| 278 | 278 | // %COUNTRY_CENTROIDS table; centroids are approximate (good enough |
| 279 | 279 | // for label placement). Traffic dots come from window.GLOBE_DATA; |
| 280 | 280 | // labels are emitted for every entry in this table so the user can |
| 281 | 281 | // identify "what country am I looking at?" at a glance. |
| 282 | 282 | var COUNTRY_CENTROIDS = [ |
| 283 | 283 | { code:'US', name:'United States', lat:39.5, lng:-98.5 }, |
| 284 | 284 | { code:'CA', name:'Canada', lat:56.1, lng:-106.3 }, |
| 285 | 285 | { code:'MX', name:'Mexico', lat:23.6, lng:-102.5 }, |
| 286 | 286 | { code:'GT', name:'Guatemala', lat:15.8, lng:-90.2 }, |
| 287 | 287 | { code:'CR', name:'Costa Rica', lat:9.7, lng:-83.7 }, |
| 288 | 288 | { code:'PA', name:'Panama', lat:8.5, lng:-80.7 }, |
| 289 | 289 | { code:'CU', name:'Cuba', lat:21.5, lng:-77.7 }, |
| 290 | 290 | { code:'DO', name:'Dominican Republic', lat:18.7, lng:-70.1 }, |
| 291 | 291 | { code:'BR', name:'Brazil', lat:-14.2, lng:-51.9 }, |
| 292 | 292 | { code:'AR', name:'Argentina', lat:-38.4, lng:-63.6 }, |
| 293 | 293 | { code:'CL', name:'Chile', lat:-35.6, lng:-71.5 }, |
| 294 | 294 | { code:'CO', name:'Colombia', lat:4.6, lng:-74.0 }, |
| 295 | 295 | { code:'PE', name:'Peru', lat:-9.2, lng:-75.0 }, |
| 296 | 296 | { code:'VE', name:'Venezuela', lat:6.4, lng:-66.6 }, |
| 297 | 297 | { code:'EC', name:'Ecuador', lat:-1.8, lng:-78.2 }, |
| 298 | 298 | { code:'UY', name:'Uruguay', lat:-32.5, lng:-55.8 }, |
| 299 | 299 | { code:'GB', name:'United Kingdom', lat:55.4, lng:-3.4 }, |
| 300 | 300 | { code:'IE', name:'Ireland', lat:53.4, lng:-8.2 }, |
| 301 | 301 | { code:'FR', name:'France', lat:46.2, lng:2.2 }, |
| 302 | 302 | { code:'DE', name:'Germany', lat:51.2, lng:10.4 }, |
| 303 | 303 | { code:'NL', name:'Netherlands', lat:52.1, lng:5.3 }, |
| 304 | 304 | { code:'BE', name:'Belgium', lat:50.5, lng:4.5 }, |
| 305 | 305 | { code:'LU', name:'Luxembourg', lat:49.8, lng:6.1 }, |
| 306 | 306 | { code:'CH', name:'Switzerland', lat:46.8, lng:8.2 }, |
| 307 | 307 | { code:'AT', name:'Austria', lat:47.5, lng:14.6 }, |
| 308 | 308 | { code:'IT', name:'Italy', lat:41.9, lng:12.6 }, |
| 309 | 309 | { code:'ES', name:'Spain', lat:40.5, lng:-3.7 }, |
| 310 | 310 | { code:'PT', name:'Portugal', lat:39.4, lng:-8.2 }, |
| 311 | 311 | { code:'DK', name:'Denmark', lat:56.3, lng:9.5 }, |
| 312 | 312 | { code:'NO', name:'Norway', lat:60.5, lng:8.5 }, |
| 313 | 313 | { code:'SE', name:'Sweden', lat:60.1, lng:18.6 }, |
| 314 | 314 | { code:'FI', name:'Finland', lat:61.9, lng:25.7 }, |
| 315 | 315 | { code:'PL', name:'Poland', lat:51.9, lng:19.1 }, |
| 316 | 316 | { code:'CZ', name:'Czechia', lat:49.8, lng:15.5 }, |
| 317 | 317 | { code:'SK', name:'Slovakia', lat:48.7, lng:19.7 }, |
| 318 | 318 | { code:'HU', name:'Hungary', lat:47.2, lng:19.5 }, |
| 319 | 319 | { code:'RO', name:'Romania', lat:45.9, lng:25.0 }, |
| 320 | 320 | { code:'BG', name:'Bulgaria', lat:42.7, lng:25.5 }, |
| 321 | 321 | { code:'GR', name:'Greece', lat:39.1, lng:21.8 }, |
| 322 | 322 | { code:'UA', name:'Ukraine', lat:48.4, lng:31.2 }, |
| 323 | 323 | { code:'RU', name:'Russia', lat:61.5, lng:105.3 }, |
| 324 | 324 | { code:'TR', name:'Turkey', lat:38.9, lng:35.2 }, |
| 325 | 325 | { code:'IL', name:'Israel', lat:31.0, lng:34.9 }, |
| 326 | 326 | { code:'SA', name:'Saudi Arabia', lat:23.9, lng:45.1 }, |
| 327 | 327 | { code:'AE', name:'United Arab Emirates',lat:23.4, lng:53.8 }, |
| 328 | 328 | { code:'IR', name:'Iran', lat:32.4, lng:53.7 }, |
| 329 | 329 | { code:'PK', name:'Pakistan', lat:30.4, lng:69.3 }, |
| 330 | 330 | { code:'IN', name:'India', lat:20.6, lng:78.9 }, |
| 331 | 331 | { code:'BD', name:'Bangladesh', lat:23.7, lng:90.4 }, |
| 332 | 332 | { code:'LK', name:'Sri Lanka', lat:7.9, lng:80.8 }, |
| 333 | 333 | { code:'NP', name:'Nepal', lat:28.4, lng:84.1 }, |
| 334 | 334 | { code:'CN', name:'China', lat:35.9, lng:104.2 }, |
| 335 | 335 | { code:'JP', name:'Japan', lat:36.2, lng:138.3 }, |
| 336 | 336 | { code:'KR', name:'South Korea', lat:35.9, lng:127.8 }, |
| 337 | 337 | { code:'TW', name:'Taiwan', lat:23.7, lng:121.0 }, |
| 338 | 338 | { code:'HK', name:'Hong Kong', lat:22.4, lng:114.1 }, |
| 339 | 339 | { code:'SG', name:'Singapore', lat:1.4, lng:103.8 }, |
| 340 | 340 | { code:'MY', name:'Malaysia', lat:4.2, lng:101.9 }, |
| 341 | 341 | { code:'TH', name:'Thailand', lat:15.9, lng:100.9 }, |
| 342 | 342 | { code:'VN', name:'Vietnam', lat:14.1, lng:108.3 }, |
| 343 | 343 | { code:'PH', name:'Philippines', lat:12.9, lng:121.8 }, |
| 344 | 344 | { code:'ID', name:'Indonesia', lat:-0.8, lng:113.9 }, |
| 345 | 345 | { code:'AU', name:'Australia', lat:-25.3, lng:133.8 }, |
| 346 | 346 | { code:'NZ', name:'New Zealand', lat:-40.9, lng:174.9 }, |
| 347 | 347 | { code:'ZA', name:'South Africa', lat:-30.6, lng:22.9 }, |
| 348 | 348 | { code:'NG', name:'Nigeria', lat:9.1, lng:8.7 }, |
| 349 | 349 | { code:'EG', name:'Egypt', lat:26.8, lng:30.8 }, |
| 350 | 350 | { code:'MA', name:'Morocco', lat:31.8, lng:-7.1 }, |
| 351 | 351 | { code:'KE', name:'Kenya', lat:-0.0, lng:37.9 }, |
| 352 | 352 | { code:'ET', name:'Ethiopia', lat:9.1, lng:40.5 }, |
| 353 | 353 | { code:'GH', name:'Ghana', lat:7.9, lng:-1.0 }, |
| 354 | 354 | { code:'DZ', name:'Algeria', lat:28.0, lng:1.7 }, |
| 355 | 355 | { code:'TN', name:'Tunisia', lat:33.9, lng:9.5 }, |
| 356 | 356 | // ---- Expanded set -------------------------------------------------- |
| 357 | 357 | // Caribbean / Central America |
| 358 | 358 | { code:'JM', name:'Jamaica', lat:18.1, lng:-77.3 }, |
| 359 | 359 | { code:'HT', name:'Haiti', lat:18.9, lng:-72.3 }, |
| 360 | 360 | { code:'PR', name:'Puerto Rico', lat:18.2, lng:-66.6 }, |
| 361 | 361 | { code:'TT', name:'Trinidad and Tobago', lat:10.7, lng:-61.2 }, |
| 362 | 362 | { code:'BZ', name:'Belize', lat:17.2, lng:-88.5 }, |
| 363 | 363 | { code:'HN', name:'Honduras', lat:15.2, lng:-86.2 }, |
| 364 | 364 | { code:'NI', name:'Nicaragua', lat:12.9, lng:-85.2 }, |
| 365 | 365 | { code:'SV', name:'El Salvador', lat:13.8, lng:-88.9 }, |
| 366 | 366 | // South America (more) |
| 367 | 367 | { code:'BO', name:'Bolivia', lat:-16.3, lng:-63.6 }, |
| 368 | 368 | { code:'PY', name:'Paraguay', lat:-23.4, lng:-58.4 }, |
| 369 | 369 | { code:'GY', name:'Guyana', lat:4.9, lng:-58.9 }, |
| 370 | 370 | { code:'SR', name:'Suriname', lat:3.9, lng:-56.0 }, |
| 371 | 371 | // Europe (more) |
| 372 | 372 | { code:'IS', name:'Iceland', lat:64.9, lng:-19.0 }, |
| 373 | 373 | { code:'EE', name:'Estonia', lat:58.6, lng:25.0 }, |
| 374 | 374 | { code:'LV', name:'Latvia', lat:56.9, lng:24.6 }, |
| 375 | 375 | { code:'LT', name:'Lithuania', lat:55.2, lng:23.9 }, |
| 376 | 376 | { code:'BY', name:'Belarus', lat:53.7, lng:27.9 }, |
| 377 | 377 | { code:'MD', name:'Moldova', lat:47.4, lng:28.4 }, |
| 378 | 378 | { code:'HR', name:'Croatia', lat:45.1, lng:15.2 }, |
| 379 | 379 | { code:'SI', name:'Slovenia', lat:46.1, lng:14.9 }, |
| 380 | 380 | { code:'BA', name:'Bosnia and Herzegovina',lat:43.9,lng:17.7 }, |
| 381 | 381 | { code:'RS', name:'Serbia', lat:44.0, lng:21.0 }, |
| 382 | 382 | { code:'AL', name:'Albania', lat:41.2, lng:20.2 }, |
| 383 | 383 | { code:'MK', name:'North Macedonia', lat:41.6, lng:21.7 }, |
| 384 | 384 | { code:'ME', name:'Montenegro', lat:42.7, lng:19.4 }, |
| 385 | 385 | // Middle East (more) |
| 386 | 386 | { code:'IQ', name:'Iraq', lat:33.2, lng:43.7 }, |
| 387 | 387 | { code:'JO', name:'Jordan', lat:31.0, lng:36.0 }, |
| 388 | 388 | { code:'LB', name:'Lebanon', lat:33.9, lng:35.9 }, |
| 389 | 389 | { code:'SY', name:'Syria', lat:34.8, lng:38.9 }, |
| 390 | 390 | { code:'OM', name:'Oman', lat:21.5, lng:55.9 }, |
| 391 | 391 | { code:'QA', name:'Qatar', lat:25.3, lng:51.2 }, |
| 392 | 392 | { code:'KW', name:'Kuwait', lat:29.3, lng:47.5 }, |
| 393 | 393 | { code:'BH', name:'Bahrain', lat:26.0, lng:50.6 }, |
| 394 | 394 | { code:'YE', name:'Yemen', lat:15.6, lng:48.5 }, |
| 395 | 395 | { code:'AZ', name:'Azerbaijan', lat:40.1, lng:47.6 }, |
| 396 | 396 | { code:'GE', name:'Georgia', lat:42.3, lng:43.4 }, |
| 397 | 397 | { code:'AM', name:'Armenia', lat:40.1, lng:45.0 }, |
| 398 | 398 | // Central + South Asia (more) |
| 399 | 399 | { code:'KZ', name:'Kazakhstan', lat:48.0, lng:66.9 }, |
| 400 | 400 | { code:'UZ', name:'Uzbekistan', lat:41.4, lng:64.6 }, |
| 401 | 401 | { code:'TM', name:'Turkmenistan', lat:38.9, lng:59.6 }, |
| 402 | 402 | { code:'KG', name:'Kyrgyzstan', lat:41.2, lng:74.8 }, |
| 403 | 403 | { code:'TJ', name:'Tajikistan', lat:38.9, lng:71.3 }, |
| 404 | 404 | { code:'AF', name:'Afghanistan', lat:33.9, lng:67.7 }, |
| 405 | 405 | { code:'MN', name:'Mongolia', lat:46.9, lng:103.8 }, |
| 406 | 406 | // Southeast Asia (more) |
| 407 | 407 | { code:'MM', name:'Myanmar', lat:21.9, lng:95.9 }, |
| 408 | 408 | { code:'KH', name:'Cambodia', lat:12.6, lng:104.9 }, |
| 409 | 409 | { code:'LA', name:'Laos', lat:19.9, lng:102.5 }, |
| 410 | 410 | // Africa (more) |
| 411 | 411 | { code:'SN', name:'Senegal', lat:14.5, lng:-14.5 }, |
| 412 | 412 | { code:'CI', name:'Cote d\'Ivoire', lat:7.5, lng:-5.5 }, |
| 413 | 413 | { code:'CM', name:'Cameroon', lat:7.4, lng:12.4 }, |
| 414 | 414 | { code:'AO', name:'Angola', lat:-11.2, lng:17.9 }, |
| 415 | 415 | { code:'MZ', name:'Mozambique', lat:-18.7, lng:35.5 }, |
| 416 | 416 | { code:'UG', name:'Uganda', lat:1.4, lng:32.3 }, |
| 417 | 417 | { code:'TZ', name:'Tanzania', lat:-6.4, lng:34.9 }, |
| 418 | 418 | { code:'ZW', name:'Zimbabwe', lat:-19.0, lng:29.2 }, |
| 419 | 419 | { code:'MG', name:'Madagascar', lat:-18.8, lng:46.9 }, |
| 420 | 420 | { code:'RW', name:'Rwanda', lat:-2.0, lng:29.9 }, |
| 421 | 421 | { code:'ML', name:'Mali', lat:17.6, lng:-4.0 }, |
| 422 | 422 | { code:'NE', name:'Niger', lat:17.6, lng:8.1 }, |
| 423 | 423 | { code:'LY', name:'Libya', lat:26.3, lng:17.2 }, |
| 424 | 424 | { code:'SD', name:'Sudan', lat:12.9, lng:30.2 }, |
| 425 | 425 | // Oceania |
| 426 | 426 | { code:'PG', name:'Papua New Guinea', lat:-6.3, lng:143.9 }, |
| 427 | 427 | { code:'FJ', name:'Fiji', lat:-17.7, lng:178.1 } |
| 428 | 428 | ]; |
| 429 | 429 | |
| 430 | 430 | // US state centroids + abbreviations. Used when the user zooms in |
| 431 | 431 | // far enough that the country dot would crowd the view -- we drop |
| 432 | 432 | // the country dot and show state-level abbreviations instead. |
| 433 | 433 | // Centroids are approximate (good enough for label placement). |
| 434 | 434 | var US_STATES = [ |
| 435 | 435 | { code:'AL', name:'Alabama', lat:32.8, lng: -86.8 }, |
| 436 | 436 | { code:'AK', name:'Alaska', lat:63.6, lng:-152.4 }, |
| 437 | 437 | { code:'AZ', name:'Arizona', lat:34.3, lng:-111.7 }, |
| 438 | 438 | { code:'AR', name:'Arkansas', lat:34.9, lng: -92.4 }, |
| 439 | 439 | { code:'CA', name:'California', lat:37.2, lng:-119.4 }, |
| 440 | 440 | { code:'CO', name:'Colorado', lat:39.0, lng:-105.5 }, |
| 441 | 441 | { code:'CT', name:'Connecticut', lat:41.6, lng: -72.7 }, |
| 442 | 442 | { code:'DE', name:'Delaware', lat:39.0, lng: -75.5 }, |
| 443 | 443 | { code:'FL', name:'Florida', lat:28.5, lng: -82.4 }, |
| 444 | 444 | { code:'GA', name:'Georgia', lat:32.6, lng: -83.4 }, |
| 445 | 445 | { code:'HI', name:'Hawaii', lat:20.6, lng:-157.5 }, |
| 446 | 446 | { code:'ID', name:'Idaho', lat:44.3, lng:-114.7 }, |
| 447 | 447 | { code:'IL', name:'Illinois', lat:40.0, lng: -89.2 }, |
| 448 | 448 | { code:'IN', name:'Indiana', lat:39.9, lng: -86.3 }, |
| 449 | 449 | { code:'IA', name:'Iowa', lat:42.0, lng: -93.5 }, |
| 450 | 450 | { code:'KS', name:'Kansas', lat:38.5, lng: -98.4 }, |
| 451 | 451 | { code:'KY', name:'Kentucky', lat:37.6, lng: -85.0 }, |
| 452 | 452 | { code:'LA', name:'Louisiana', lat:31.2, lng: -91.9 }, |
| 453 | 453 | { code:'ME', name:'Maine', lat:45.3, lng: -69.2 }, |
| 454 | 454 | { code:'MD', name:'Maryland', lat:39.0, lng: -76.7 }, |
| 455 | 455 | { code:'MA', name:'Massachusetts', lat:42.3, lng: -71.8 }, |
| 456 | 456 | { code:'MI', name:'Michigan', lat:44.3, lng: -85.4 }, |
| 457 | 457 | { code:'MN', name:'Minnesota', lat:46.3, lng: -94.3 }, |
| 458 | 458 | { code:'MS', name:'Mississippi', lat:32.7, lng: -89.7 }, |
| 459 | 459 | { code:'MO', name:'Missouri', lat:38.4, lng: -92.4 }, |
| 460 | 460 | { code:'MT', name:'Montana', lat:47.0, lng:-109.6 }, |
| 461 | 461 | { code:'NE', name:'Nebraska', lat:41.5, lng: -99.8 }, |
| 462 | 462 | { code:'NV', name:'Nevada', lat:39.3, lng:-116.6 }, |
| 463 | 463 | { code:'NH', name:'New Hampshire', lat:43.7, lng: -71.6 }, |
| 464 | 464 | { code:'NJ', name:'New Jersey', lat:40.3, lng: -74.5 }, |
| 465 | 465 | { code:'NM', name:'New Mexico', lat:34.4, lng:-106.1 }, |
| 466 | 466 | { code:'NY', name:'New York', lat:42.9, lng: -75.5 }, |
| 467 | 467 | { code:'NC', name:'North Carolina', lat:35.6, lng: -79.8 }, |
| 468 | 468 | { code:'ND', name:'North Dakota', lat:47.5, lng:-100.3 }, |
| 469 | 469 | { code:'OH', name:'Ohio', lat:40.3, lng: -82.8 }, |
| 470 | 470 | { code:'OK', name:'Oklahoma', lat:35.6, lng: -97.5 }, |
| 471 | 471 | { code:'OR', name:'Oregon', lat:43.8, lng:-120.6 }, |
| 472 | 472 | { code:'PA', name:'Pennsylvania', lat:40.6, lng: -77.2 }, |
| 473 | 473 | { code:'RI', name:'Rhode Island', lat:41.7, lng: -71.5 }, |
| 474 | 474 | { code:'SC', name:'South Carolina', lat:33.9, lng: -80.9 }, |
| 475 | 475 | { code:'SD', name:'South Dakota', lat:44.3, lng: -99.4 }, |
| 476 | 476 | { code:'TN', name:'Tennessee', lat:35.8, lng: -86.7 }, |
| 477 | 477 | { code:'TX', name:'Texas', lat:31.1, lng: -97.6 }, |
| 478 | 478 | { code:'UT', name:'Utah', lat:40.2, lng:-111.9 }, |
| 479 | 479 | { code:'VT', name:'Vermont', lat:44.0, lng: -72.7 }, |
| 480 | 480 | { code:'VA', name:'Virginia', lat:37.8, lng: -78.2 }, |
| 481 | 481 | { code:'WA', name:'Washington', lat:47.4, lng:-121.5 }, |
| 482 | 482 | { code:'WV', name:'West Virginia', lat:38.5, lng: -80.6 }, |
| 483 | 483 | { code:'WI', name:'Wisconsin', lat:44.3, lng: -89.6 }, |
| 484 | 484 | { code:'WY', name:'Wyoming', lat:42.8, lng:-107.3 }, |
| 485 | 485 | { code:'DC', name:'District of Columbia', lat:38.9, lng: -77.0 } |
| 486 | 486 | ]; |
| 487 | 487 | |
| 488 | 488 | // ===== Per-country subdivisions ====================================== |
| 489 | 489 | // Map keyed by ISO country code. Each entry: [{ code, name, lat, lng }] |
| 490 | 490 | // for the subdivisions (states/provinces/regions) we draw when the |
| 491 | 491 | // user zooms in. US gets the full 50-state set above; the rest below |
| 492 | 492 | // are major-administrative-divisions only. Inactive entries render as |
| 493 | 493 | // just an abbreviation label (no dot), active ones (with traffic in |
| 494 | 494 | // window.GLOBE_STATE_DATA) get the purple dot + count -- same rules |
| 495 | 495 | // the US set follows. |
| 496 | 496 | // |
| 497 | 497 | // Server-side per-region traffic is currently US-only -- non-US |
| 498 | 498 | // subdivisions render as geography labels for now and start |
| 499 | 499 | // populating with real counts as the tracking_sessions.region |
| 500 | 500 | // column gets backfilled for those countries. |
| 501 | 501 | // ==================================================================== |
| 502 | 502 | var SUBDIVISIONS = { |
| 503 | 503 | US: US_STATES, |
| 504 | 504 | |
| 505 | 505 | // Canada -- 13 provinces and territories |
| 506 | 506 | CA: [ |
| 507 | 507 | { code:'AB', name:'Alberta', lat:55.0, lng:-115.0 }, |
| 508 | 508 | { code:'BC', name:'British Columbia', lat:54.7, lng:-125.6 }, |
| 509 | 509 | { code:'MB', name:'Manitoba', lat:53.7, lng: -98.8 }, |
| 510 | 510 | { code:'NB', name:'New Brunswick', lat:46.6, lng: -66.4 }, |
| 511 | 511 | { code:'NL', name:'Newfoundland & Labrador',lat:53.1, lng: -57.7 }, |
| 512 | 512 | { code:'NS', name:'Nova Scotia', lat:45.0, lng: -63.0 }, |
| 513 | 513 | { code:'NT', name:'Northwest Territories', lat:64.8, lng:-124.8 }, |
| 514 | 514 | { code:'NU', name:'Nunavut', lat:70.0, lng: -90.0 }, |
| 515 | 515 | { code:'ON', name:'Ontario', lat:51.2, lng: -85.3 }, |
| 516 | 516 | { code:'PE', name:'Prince Edward Island', lat:46.3, lng: -63.0 }, |
| 517 | 517 | { code:'QC', name:'Quebec', lat:52.0, lng: -71.0 }, |
| 518 | 518 | { code:'SK', name:'Saskatchewan', lat:54.0, lng:-106.0 }, |
| 519 | 519 | { code:'YT', name:'Yukon', lat:64.3, lng:-135.0 } |
| 520 | 520 | ], |
| 521 | 521 | |
| 522 | 522 | // Mexico -- 32 federal entities (31 states + Mexico City) |
| 523 | 523 | MX: [ |
| 524 | 524 | { code:'AGU', name:'Aguascalientes', lat:22.0, lng:-102.3 }, |
| 525 | 525 | { code:'BCN', name:'Baja California', lat:30.8, lng:-115.3 }, |
| 526 | 526 | { code:'BCS', name:'Baja California Sur',lat:26.0, lng:-111.7 }, |
| 527 | 527 | { code:'CAM', name:'Campeche', lat:18.8, lng: -90.4 }, |
| 528 | 528 | { code:'CHP', name:'Chiapas', lat:16.7, lng: -92.6 }, |
| 529 | 529 | { code:'CHH', name:'Chihuahua', lat:28.6, lng:-106.0 }, |
| 530 | 530 | { code:'COA', name:'Coahuila', lat:27.3, lng:-102.0 }, |
| 531 | 531 | { code:'COL', name:'Colima', lat:19.1, lng:-103.7 }, |
| 532 | 532 | { code:'CMX', name:'Mexico City', lat:19.4, lng: -99.1 }, |
| 533 | 533 | { code:'DUR', name:'Durango', lat:24.6, lng:-104.7 }, |
| 534 | 534 | { code:'GUA', name:'Guanajuato', lat:21.0, lng:-101.3 }, |
| 535 | 535 | { code:'GRO', name:'Guerrero', lat:17.4, lng: -99.5 }, |
| 536 | 536 | { code:'HID', name:'Hidalgo', lat:20.5, lng: -98.8 }, |
| 537 | 537 | { code:'JAL', name:'Jalisco', lat:20.7, lng:-103.3 }, |
| 538 | 538 | { code:'MEX', name:'Mexico State', lat:19.3, lng: -99.6 }, |
| 539 | 539 | { code:'MIC', name:'Michoacan', lat:19.6, lng:-101.7 }, |
| 540 | 540 | { code:'MOR', name:'Morelos', lat:18.7, lng: -99.2 }, |
| 541 | 541 | { code:'NAY', name:'Nayarit', lat:21.7, lng:-104.9 }, |
| 542 | 542 | { code:'NLE', name:'Nuevo Leon', lat:25.6, lng:-100.0 }, |
| 543 | 543 | { code:'OAX', name:'Oaxaca', lat:17.1, lng: -96.7 }, |
| 544 | 544 | { code:'PUE', name:'Puebla', lat:19.0, lng: -97.9 }, |
| 545 | 545 | { code:'QUE', name:'Queretaro', lat:20.6, lng:-100.4 }, |
| 546 | 546 | { code:'ROO', name:'Quintana Roo', lat:19.6, lng: -88.0 }, |
| 547 | 547 | { code:'SLP', name:'San Luis Potosi', lat:22.6, lng:-100.4 }, |
| 548 | 548 | { code:'SIN', name:'Sinaloa', lat:25.0, lng:-107.5 }, |
| 549 | 549 | { code:'SON', name:'Sonora', lat:29.3, lng:-110.3 }, |
| 550 | 550 | { code:'TAB', name:'Tabasco', lat:18.0, lng: -92.9 }, |
| 551 | 551 | { code:'TAM', name:'Tamaulipas', lat:24.3, lng: -98.6 }, |
| 552 | 552 | { code:'TLA', name:'Tlaxcala', lat:19.4, lng: -98.2 }, |
| 553 | 553 | { code:'VER', name:'Veracruz', lat:19.2, lng: -96.5 }, |
| 554 | 554 | { code:'YUC', name:'Yucatan', lat:20.7, lng: -89.1 }, |
| 555 | 555 | { code:'ZAC', name:'Zacatecas', lat:23.3, lng:-103.0 } |
| 556 | 556 | ], |
| 557 | 557 | |
| 558 | 558 | // United Kingdom -- 4 constituent nations |
| 559 | 559 | GB: [ |
| 560 | 560 | { code:'ENG', name:'England', lat:52.4, lng: -1.5 }, |
| 561 | 561 | { code:'SCT', name:'Scotland', lat:56.5, lng: -4.2 }, |
| 562 | 562 | { code:'WLS', name:'Wales', lat:52.4, lng: -3.8 }, |
| 563 | 563 | { code:'NIR', name:'Northern Ireland', lat:54.6, lng: -6.5 } |
| 564 | 564 | ], |
| 565 | 565 | |
| 566 | 566 | // Australia -- 6 states + 2 mainland territories |
| 567 | 567 | AU: [ |
| 568 | 568 | { code:'NSW', name:'New South Wales', lat:-32.2, lng:147.0 }, |
| 569 | 569 | { code:'VIC', name:'Victoria', lat:-36.5, lng:144.0 }, |
| 570 | 570 | { code:'QLD', name:'Queensland', lat:-22.0, lng:144.0 }, |
| 571 | 571 | { code:'WA', name:'Western Australia', lat:-26.0, lng:121.5 }, |
| 572 | 572 | { code:'SA', name:'South Australia', lat:-30.0, lng:135.0 }, |
| 573 | 573 | { code:'TAS', name:'Tasmania', lat:-42.0, lng:146.6 }, |
| 574 | 574 | { code:'NT', name:'Northern Territory', lat:-19.5, lng:133.4 }, |
| 575 | 575 | { code:'ACT', name:'Australian Capital Territory',lat:-35.5,lng:149.0 } |
| 576 | 576 | ], |
| 577 | 577 | |
| 578 | 578 | // Germany -- 16 Bundeslander |
| 579 | 579 | DE: [ |
| 580 | 580 | { code:'BW', name:'Baden-Wurttemberg', lat:48.6, lng:9.0 }, |
| 581 | 581 | { code:'BY', name:'Bavaria', lat:48.8, lng:11.5 }, |
| 582 | 582 | { code:'BE', name:'Berlin', lat:52.5, lng:13.4 }, |
| 583 | 583 | { code:'BB', name:'Brandenburg', lat:52.4, lng:13.6 }, |
| 584 | 584 | { code:'HB', name:'Bremen', lat:53.1, lng:8.8 }, |
| 585 | 585 | { code:'HH', name:'Hamburg', lat:53.6, lng:10.0 }, |
| 586 | 586 | { code:'HE', name:'Hesse', lat:50.7, lng:9.0 }, |
| 587 | 587 | { code:'MV', name:'Mecklenburg-Vorpommern', lat:53.6, lng:12.7 }, |
| 588 | 588 | { code:'NI', name:'Lower Saxony', lat:52.6, lng:9.8 }, |
| 589 | 589 | { code:'NW', name:'North Rhine-Westphalia', lat:51.4, lng:7.7 }, |
| 590 | 590 | { code:'RP', name:'Rhineland-Palatinate', lat:50.0, lng:7.3 }, |
| 591 | 591 | { code:'SL', name:'Saarland', lat:49.4, lng:7.0 }, |
| 592 | 592 | { code:'SN', name:'Saxony', lat:51.1, lng:13.2 }, |
| 593 | 593 | { code:'ST', name:'Saxony-Anhalt', lat:51.9, lng:11.7 }, |
| 594 | 594 | { code:'SH', name:'Schleswig-Holstein', lat:54.2, lng:9.7 }, |
| 595 | 595 | { code:'TH', name:'Thuringia', lat:50.9, lng:11.0 } |
| 596 | 596 | ], |
| 597 | 597 | |
| 598 | 598 | // Brazil -- 26 states + Federal District |
| 599 | 599 | BR: [ |
| 600 | 600 | { code:'AC', name:'Acre', lat:-9.0, lng:-70.0 }, |
| 601 | 601 | { code:'AL', name:'Alagoas', lat:-9.6, lng:-36.8 }, |
| 602 | 602 | { code:'AP', name:'Amapa', lat:1.4, lng:-51.8 }, |
| 603 | 603 | { code:'AM', name:'Amazonas', lat:-3.5, lng:-62.0 }, |
| 604 | 604 | { code:'BA', name:'Bahia', lat:-12.0, lng:-41.7 }, |
| 605 | 605 | { code:'CE', name:'Ceara', lat:-5.4, lng:-39.3 }, |
| 606 | 606 | { code:'DF', name:'Federal District', lat:-15.8, lng:-47.9 }, |
| 607 | 607 | { code:'ES', name:'Espirito Santo', lat:-19.2, lng:-40.3 }, |
| 608 | 608 | { code:'GO', name:'Goias', lat:-15.8, lng:-49.8 }, |
| 609 | 609 | { code:'MA', name:'Maranhao', lat:-5.0, lng:-45.3 }, |
| 610 | 610 | { code:'MT', name:'Mato Grosso', lat:-13.0, lng:-55.0 }, |
| 611 | 611 | { code:'MS', name:'Mato Grosso do Sul', lat:-20.5, lng:-54.5 }, |
| 612 | 612 | { code:'MG', name:'Minas Gerais', lat:-18.5, lng:-44.4 }, |
| 613 | 613 | { code:'PA', name:'Para', lat:-3.5, lng:-52.5 }, |
| 614 | 614 | { code:'PB', name:'Paraiba', lat:-7.2, lng:-36.7 }, |
| 615 | 615 | { code:'PR', name:'Parana', lat:-24.9, lng:-51.5 }, |
| 616 | 616 | { code:'PE', name:'Pernambuco', lat:-8.5, lng:-37.8 }, |
| 617 | 617 | { code:'PI', name:'Piaui', lat:-7.7, lng:-43.0 }, |
| 618 | 618 | { code:'RJ', name:'Rio de Janeiro', lat:-22.3, lng:-42.6 }, |
| 619 | 619 | { code:'RN', name:'Rio Grande do Norte', lat:-5.8, lng:-36.6 }, |
| 620 | 620 | { code:'RS', name:'Rio Grande do Sul', lat:-30.0, lng:-53.5 }, |
| 621 | 621 | { code:'RO', name:'Rondonia', lat:-11.5, lng:-63.0 }, |
| 622 | 622 | { code:'RR', name:'Roraima', lat:2.0, lng:-61.5 }, |
| 623 | 623 | { code:'SC', name:'Santa Catarina', lat:-27.3, lng:-50.0 }, |
| 624 | 624 | { code:'SP', name:'Sao Paulo', lat:-22.2, lng:-48.6 }, |
| 625 | 625 | { code:'SE', name:'Sergipe', lat:-10.6, lng:-37.4 }, |
| 626 | 626 | { code:'TO', name:'Tocantins', lat:-10.2, lng:-48.3 } |
| 627 | 627 | ], |
| 628 | 628 | |
| 629 | 629 | // India -- 15 most-populous states + Delhi (full set is 28+8, the |
| 630 | 630 | // rest can be added incrementally without touching anything else) |
| 631 | 631 | IN: [ |
| 632 | 632 | { code:'MH', name:'Maharashtra', lat:19.7, lng:75.7 }, |
| 633 | 633 | { code:'UP', name:'Uttar Pradesh', lat:26.8, lng:80.9 }, |
| 634 | 634 | { code:'TN', name:'Tamil Nadu', lat:11.1, lng:78.7 }, |
| 635 | 635 | { code:'KA', name:'Karnataka', lat:15.3, lng:75.7 }, |
| 636 | 636 | { code:'WB', name:'West Bengal', lat:22.9, lng:87.9 }, |
| 637 | 637 | { code:'GJ', name:'Gujarat', lat:22.3, lng:71.2 }, |
| 638 | 638 | { code:'RJ', name:'Rajasthan', lat:27.0, lng:74.2 }, |
| 639 | 639 | { code:'AP', name:'Andhra Pradesh', lat:15.9, lng:80.0 }, |
| 640 | 640 | { code:'TS', name:'Telangana', lat:18.1, lng:79.0 }, |
| 641 | 641 | { code:'KL', name:'Kerala', lat:10.9, lng:76.3 }, |
| 642 | 642 | { code:'MP', name:'Madhya Pradesh', lat:23.5, lng:78.0 }, |
| 643 | 643 | { code:'DL', name:'Delhi', lat:28.6, lng:77.2 }, |
| 644 | 644 | { code:'PB', name:'Punjab', lat:31.1, lng:75.3 }, |
| 645 | 645 | { code:'HR', name:'Haryana', lat:29.1, lng:76.1 }, |
| 646 | 646 | { code:'OD', name:'Odisha', lat:20.9, lng:85.1 }, |
| 647 | 647 | { code:'BR', name:'Bihar', lat:25.1, lng:85.3 }, |
| 648 | 648 | { code:'AS', name:'Assam', lat:26.2, lng:92.9 }, |
| 649 | 649 | { code:'JK', name:'Jammu & Kashmir', lat:33.8, lng:75.0 } |
| 650 | 650 | ], |
| 651 | 651 | |
| 652 | 652 | // Japan -- 8 traditional regions (47 prefectures is overkill at |
| 653 | 653 | // this zoom; regions read cleaner and still cover everything) |
| 654 | 654 | JP: [ |
| 655 | 655 | { code:'HOK', name:'Hokkaido', lat:43.5, lng:142.5 }, |
| 656 | 656 | { code:'TOH', name:'Tohoku', lat:39.0, lng:141.0 }, |
| 657 | 657 | { code:'KAN', name:'Kanto', lat:36.0, lng:139.5 }, |
| 658 | 658 | { code:'CHU', name:'Chubu', lat:36.5, lng:137.5 }, |
| 659 | 659 | { code:'KIN', name:'Kansai', lat:34.7, lng:135.5 }, |
| 660 | 660 | { code:'CHG', name:'Chugoku', lat:34.7, lng:133.0 }, |
| 661 | 661 | { code:'SHI', name:'Shikoku', lat:33.7, lng:133.5 }, |
| 662 | 662 | { code:'KYU', name:'Kyushu', lat:32.5, lng:130.7 } |
| 663 | 663 | ] |
| 664 | 664 | }; |
| 665 | 665 | |
| 666 | 666 | function initGlobes() { |
| 667 | 667 | var hosts = document.querySelectorAll('[data-globe]'); |
| 668 | 668 | for (var i = 0; i < hosts.length; i++) initOne(hosts[i]); |
| 669 | 669 | } |
| 670 | 670 | |
| 671 | 671 | function initOne(host) { |
| 672 | 672 | var W = parseInt(host.getAttribute('data-width') || '800', 10); |
| 673 | 673 | var H = parseInt(host.getAttribute('data-height') || '520', 10); |
| 674 | 674 | var DRILL_HREF = host.getAttribute('data-drill-href') || '/admin_visitors.cgi'; |
| 675 | 675 | var TUTORIAL = host.getAttribute('data-tutorial') === '1'; |
| 676 | 676 | // Auto-rotation is opt-in only -- the per-frame DOM rewrites it |
| 677 | 677 | // requires trigger Chrome's scroll-anchoring on long pages, which |
| 678 | 678 | // makes the whole page lurch up/down. Set data-autospin="1" only |
| 679 | 679 | // on hosts that can guarantee they won't scroll (e.g. focus mode). |
| 680 | 680 | var AUTOSPIN = host.getAttribute('data-autospin') === '1'; |
| 681 | 681 | |
| 682 | 682 | // Defensively disable scroll anchoring on the host so even if a |
| 683 | 683 | // frame paint changes its measured height by a fraction of a pixel, |
| 684 | 684 | // the browser doesn't try to compensate by scrolling the page. |
| 685 | 685 | host.style.overflowAnchor = 'none'; |
| 686 | 686 | host.style.overflow = 'hidden'; |
| 687 | 687 | host.style.contain = 'layout paint'; |
| 688 | 688 | |
| 689 | 689 | var data = window.GLOBE_DATA || { countries: [] }; |
| 690 | 690 | var countries = data.countries || []; |
| 691 | 691 | var maxCount = 1; |
| 692 | 692 | for (var k = 0; k < countries.length; k++) { |
| 693 | 693 | if ((countries[k].count || 0) > maxCount) maxCount = countries[k].count; |
| 694 | 694 | } |
| 695 | 695 | |
| 696 | 696 | // Viewing state -- the rAF tick reads these. Persisted to |
| 697 | 697 | // sessionStorage on every change so the 8-second auto-refresh on |
| 698 | 698 | // admin_visitors.cgi doesn't reset the user's rotation/zoom. |
| 699 | 699 | var STATE_KEY = 'taskforge.globe.' + (host.getAttribute('data-state-key') || DRILL_HREF); |
| 700 | 700 | var lat0 = 20; |
| 701 | 701 | var lng0 = -30; |
| 702 | 702 | var R = Math.min(W, H) * 0.42; |
| 703 | 703 | try { |
| 704 | 704 | var saved = sessionStorage.getItem(STATE_KEY); |
| 705 | 705 | if (saved) { |
| 706 | 706 | var s = JSON.parse(saved); |
| 707 | 707 | if (s && typeof s.lat0 === 'number') lat0 = s.lat0; |
| 708 | 708 | if (s && typeof s.lng0 === 'number') lng0 = s.lng0; |
| 709 | 709 | if (s && typeof s.R === 'number') R = s.R; |
| 710 | 710 | } |
| 711 | 711 | } catch (e) { /* sessionStorage may be disabled -- non-fatal */ } |
| 712 | 712 | var Rtarget = R; // wheel zoom eases toward this |
| 713 | 713 | var dirty = true; // re-render flag |
| 714 | 714 | |
| 715 | 715 | function saveState() { |
| 716 | 716 | try { |
| 717 | 717 | sessionStorage.setItem(STATE_KEY, JSON.stringify({ lat0: lat0, lng0: lng0, R: R })); |
| 718 | 718 | } catch (e) { /* ignore */ } |
| 719 | 719 | } |
| 720 | 720 | |
| 721 | 721 | var SVG_NS = 'http://www.w3.org/2000/svg'; |
| 722 | 722 | var svg = document.createElementNS(SVG_NS, 'svg'); |
| 723 | 723 | svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H); |
| 724 | 724 | svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); |
| 725 | 725 | svg.style.display = 'block'; |
| 726 | 726 | svg.style.width = '100%'; |
| 727 | 727 | svg.style.height = 'auto'; |
| 728 | 728 | svg.style.userSelect = 'none'; |
| 729 | 729 | svg.style.touchAction = 'none'; |
| 730 | 730 | svg.style.cursor = 'grab'; |
| 731 | 731 | host.appendChild(svg); |
| 732 | 732 | |
| 733 | 733 | // Floating HTML tooltip layered over the SVG. |
| 734 | 734 | host.style.position = host.style.position || 'relative'; |
| 735 | 735 | var tip = document.createElement('div'); |
| 736 | 736 | tip.className = 'globe-tip'; |
| 737 | 737 | tip.style.cssText = |
| 738 | 738 | 'position:absolute;pointer-events:none;background:rgba(15,23,42,0.95);' + |
| 739 | 739 | 'border:1px solid rgba(160,26,44,0.55);border-radius:8px;' + |
| 740 | 740 | 'padding:8px 12px;color:#fff;font:600 12px/1.3 system-ui,sans-serif;' + |
| 741 | 741 | 'white-space:nowrap;box-shadow:0 6px 18px rgba(0,0,0,0.5);' + |
| 742 | 742 | 'transform:translate(-50%,-115%);opacity:0;transition:opacity 0.12s ease;' + |
| 743 | 743 | 'z-index:5;left:0;top:0'; |
| 744 | 744 | tip.innerHTML = ''; |
| 745 | 745 | host.appendChild(tip); |
| 746 | 746 | |
| 747 | 747 | // ---- Click-to-drill modal ------------------------------------------ |
| 748 | 748 | // Built once per host. Click a country/state dot -> AJAX-fetch the |
| 749 | 749 | // detail body and slide it into the modal. No page reload, the |
| 750 | 750 | // globe keeps its rotation/zoom state. |
| 751 | 751 | var modal = document.querySelector('[data-globe-modal]'); |
| 752 | 752 | if (!modal) { |
| 753 | 753 | modal = document.createElement('div'); |
| 754 | 754 | modal.setAttribute('data-globe-modal', ''); |
| 755 | 755 | modal.style.cssText = |
| 756 | 756 | 'position:fixed;inset:0;z-index:1000;display:none;' + |
| 757 | 757 | 'background:rgba(3,8,31,0.78);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);' + |
| 758 | 758 | 'align-items:flex-start;justify-content:center;padding:48px 16px;overflow-y:auto'; |
| 759 | 759 | modal.innerHTML = |
| 760 | 760 | '<div data-globe-modal-card style="max-width:920px;width:100%;background:var(--col-surface-1,#0c1729);' + |
| 761 | 761 | 'border:1px solid var(--col-border,rgba(160,26,44,0.35));border-radius:14px;padding:24px;' + |
| 762 | 762 | 'box-shadow:0 30px 60px rgba(0,0,0,0.6);position:relative">' + |
| 763 | 763 | '<button data-globe-modal-close aria-label="Close" style="' + |
| 764 | 764 | 'position:absolute;top:14px;right:14px;background:rgba(160,26,44,0.15);' + |
| 765 | 765 | 'border:1px solid rgba(160,26,44,0.5);color:#fff;border-radius:8px;' + |
| 766 | 766 | 'width:32px;height:32px;cursor:pointer;font-size:18px;line-height:1;display:grid;' + |
| 767 | 767 | 'place-items:center">×</button>' + |
| 768 | 768 | '<div data-globe-modal-body><div style="padding:48px;text-align:center;color:var(--col-text-3,#94a3b8)">Loading…</div></div>' + |
| 769 | 769 | '</div>'; |
| 770 | 770 | document.body.appendChild(modal); |
| 771 | 771 | var closeBtn = modal.querySelector('[data-globe-modal-close]'); |
| 772 | 772 | var modalCard = modal.querySelector('[data-globe-modal-card]'); |
| 773 | 773 | function closeModal() { modal.style.display = 'none'; } |
| 774 | 774 | closeBtn.addEventListener('click', closeModal); |
| 775 | 775 | modal.addEventListener('click', function (e) { |
| 776 | 776 | if (e.target === modal) closeModal(); |
| 777 | 777 | }); |
| 778 | 778 | document.addEventListener('keydown', function (e) { |
| 779 | 779 | if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); |
| 780 | 780 | }); |
| 781 | 781 | } |
| 782 | 782 | var modalBody = modal.querySelector('[data-globe-modal-body]'); |
| 783 | 783 | |
| 784 | 784 | // Last-loaded country/region so the slider can re-fetch the same |
| 785 | 785 | // scope with a new date window. |
| 786 | 786 | var currentDrillCode = ''; |
| 787 | 787 | var currentDrillRegion = ''; |
| 788 | 788 | |
| 789 | 789 | function openDrillModal(code, region, daysFrom, daysTo) { |
| 790 | 790 | modal.style.display = 'flex'; |
| 791 | 791 | modalBody.innerHTML = '<div style="padding:48px;text-align:center;color:var(--col-text-3,#94a3b8)">Loading…</div>'; |
| 792 | 792 | currentDrillCode = code || ''; |
| 793 | 793 | currentDrillRegion = region || ''; |
| 794 | 794 | // Detail fetch hits the same CGI the host page declared in |
| 795 | 795 | // data-drill-href -- admin_visitors.cgi for the admin globe, |
| 796 | 796 | // traffic_reports.cgi for the creator globe. |
| 797 | 797 | var url = DRILL_HREF + '?country_detail=' + encodeURIComponent(code); |
| 798 | 798 | if (region) url += '®ion=' + encodeURIComponent(region); |
| 799 | 799 | if (typeof daysFrom !== 'undefined' && daysFrom !== null) url += '&days_from=' + daysFrom; |
| 800 | 800 | if (typeof daysTo !== 'undefined' && daysTo !== null) url += '&days_to=' + daysTo; |
| 801 | 801 | url += '&_=' + Date.now(); |
| 802 | 802 | fetch(url, { credentials: 'same-origin' }) |
| 803 | 803 | .then(function (r) { return r.ok ? r.text() : Promise.reject(r.status); }) |
| 804 | 804 | .then(function (html) { modalBody.innerHTML = html; wireDetail(); }) |
| 805 | 805 | .catch(function () { |
| 806 | 806 | modalBody.innerHTML = |
| 807 | 807 | '<div style="padding:32px;color:#ef4444">Couldn\'t load detail. Try again in a moment.</div>'; |
| 808 | 808 | }); |
| 809 | 809 | } |
| 810 | 810 | |
| 811 | 811 | // Hook up the in-modal chart hover after each detail load. Since |
| 812 | 812 | // `innerHTML = html` doesn't execute embedded <script> tags, we |
| 813 | 813 | // wire it from this side. |
| 814 | 814 | function wireDetail() { |
| 815 | 815 | wireChartHover(); |
| 816 | 816 | } |
| 817 | 817 | |
| 818 | 818 | function wireChartHover() { |
| 819 | 819 | var card = modalBody.querySelector('[data-chart-card]'); |
| 820 | 820 | if (!card) return; |
| 821 | 821 | var svg = card.querySelector('[data-chart-svg]'); |
| 822 | 822 | var overlay = card.querySelector('[data-chart-overlay]'); |
| 823 | 823 | var line = card.querySelector('[data-chart-marker-line]'); |
| 824 | 824 | var dot = card.querySelector('[data-chart-marker-dot]'); |
| 825 | 825 | var tip = card.querySelector('[data-chart-tip]'); |
| 826 | 826 | var dataEl = card.querySelector('[data-chart-data]'); |
| 827 | 827 | if (!svg || !overlay || !line || !dot || !tip || !dataEl) return; |
| 828 | 828 | var pts; |
| 829 | 829 | try { pts = JSON.parse(dataEl.textContent || '[]'); } catch (e) { pts = []; } |
| 830 | 830 | if (!pts.length) return; |
| 831 | 831 | |
| 832 | 832 | var monthShort = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; |
| 833 | 833 | function fmtDate(ymd) { |
| 834 | 834 | if (!ymd) return ''; |
| 835 | 835 | var m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/); |
| 836 | 836 | if (!m) return ymd; |
| 837 | 837 | return monthShort[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10) + ', ' + m[1]; |
| 838 | 838 | } |
| 839 | 839 | |
| 840 | 840 | function findNearest(svgX) { |
| 841 | 841 | // pts are sorted by x already; pick the closest. |
| 842 | 842 | var lo = 0, hi = pts.length - 1, best = 0, bestD = Infinity; |
| 843 | 843 | for (var i = 0; i < pts.length; i++) { |
| 844 | 844 | var d = Math.abs(pts[i].x - svgX); |
| 845 | 845 | if (d < bestD) { bestD = d; best = i; } |
| 846 | 846 | } |
| 847 | 847 | return pts[best]; |
| 848 | 848 | } |
| 849 | 849 | |
| 850 | 850 | overlay.addEventListener('mousemove', function (e) { |
| 851 | 851 | // Convert client X to SVG user-space X via the rendered rect. |
| 852 | 852 | var rect = svg.getBoundingClientRect(); |
| 853 | 853 | var vbW = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.width : rect.width; |
| 854 | 854 | var vbH = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.height : rect.height; |
| 855 | 855 | var sx = ((e.clientX - rect.left) / rect.width) * vbW; |
| 856 | 856 | var sy_ignore = ((e.clientY - rect.top) / rect.height) * vbH; |
| 857 | 857 | var p = findNearest(sx); |
| 858 | 858 | if (!p) return; |
| 859 | 859 | line.setAttribute('x1', p.x); line.setAttribute('x2', p.x); |
| 860 | 860 | line.style.display = ''; |
| 861 | 861 | dot.setAttribute('cx', p.x); dot.setAttribute('cy', p.y); |
| 862 | 862 | dot.style.display = ''; |
| 863 | 863 | // Tooltip text + position (HTML overlay, positioned in pixels |
| 864 | 864 | // relative to the chart wrapper). |
| 865 | 865 | tip.innerHTML = fmtDate(p.date) + ' · <span style="color:#d97a86">' + p.visitors + '</span> visits'; |
| 866 | 866 | var wrapRect = tip.parentNode.getBoundingClientRect(); |
| 867 | 867 | tip.style.left = (e.clientX - wrapRect.left) + 'px'; |
| 868 | 868 | tip.style.top = (e.clientY - wrapRect.top) + 'px'; |
| 869 | 869 | tip.style.display = 'block'; |
| 870 | 870 | }); |
| 871 | 871 | overlay.addEventListener('mouseleave', function () { |
| 872 | 872 | line.style.display = 'none'; |
| 873 | 873 | dot.style.display = 'none'; |
| 874 | 874 | tip.style.display = 'none'; |
| 875 | 875 | }); |
| 876 | 876 | } |
| 877 | 877 | |
| 878 | 878 | // Intercept clicks on the modal body's [data-region-link] rows so |
| 879 | 879 | // each state in the "Traffic by state" panel reloads THIS modal |
| 880 | 880 | // scoped to that state, instead of navigating to a separate page. |
| 881 | 881 | modal.addEventListener('click', function (e) { |
| 882 | 882 | var a = e.target && e.target.closest ? e.target.closest('[data-region-link]') : null; |
| 883 | 883 | if (!a) return; |
| 884 | 884 | e.preventDefault(); |
| 885 | 885 | var href = a.getAttribute('href') || ''; |
| 886 | 886 | var cm = href.match(/[?&]country_detail=([^&]+)/); |
| 887 | 887 | var rm = href.match(/[?&]region=([^&]+)/); |
| 888 | 888 | var cc = cm ? decodeURIComponent(cm[1]) : ''; |
| 889 | 889 | var rg = rm ? decodeURIComponent(rm[1]) : ''; |
| 890 | 890 | if (cc) openDrillModal(cc, rg); |
| 891 | 891 | }); |
| 892 | 892 | |
| 893 | 893 | // ---- Static decoration: defs + stars + sphere ---------------------- |
| 894 | 894 | var defs = document.createElementNS(SVG_NS, 'defs'); |
| 895 | 895 | defs.innerHTML = |
| 896 | 896 | '<radialGradient id="g-sphere" cx="35%" cy="32%" r="75%">' + |
| 897 | 897 | '<stop offset="0%" stop-color="#4d0e15"/>' + |
| 898 | 898 | '<stop offset="55%" stop-color="#3a0a11"/>' + |
| 899 | 899 | '<stop offset="100%" stop-color="#0c1018"/>' + |
| 900 | 900 | '</radialGradient>' + |
| 901 | 901 | '<radialGradient id="g-glow" cx="50%" cy="50%" r="50%">' + |
| 902 | 902 | '<stop offset="70%" stop-color="rgba(160,26,44,0)"/>' + |
| 903 | 903 | '<stop offset="90%" stop-color="rgba(160,26,44,0.25)"/>' + |
| 904 | 904 | '<stop offset="100%" stop-color="rgba(160,26,44,0)"/>' + |
| 905 | 905 | '</radialGradient>'; |
| 906 | 906 | svg.appendChild(defs); |
| 907 | 907 | |
| 908 | 908 | var stars = document.createElementNS(SVG_NS, 'g'); |
| 909 | 909 | var seed = 1; |
| 910 | 910 | function rng() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; } |
| 911 | 911 | for (var s = 0; s < 120; s++) { |
| 912 | 912 | var dot = document.createElementNS(SVG_NS, 'circle'); |
| 913 | 913 | dot.setAttribute('cx', (rng() * W).toFixed(1)); |
| 914 | 914 | dot.setAttribute('cy', (rng() * H).toFixed(1)); |
| 915 | 915 | dot.setAttribute('r', (rng() * 1.2 + 0.3).toFixed(1)); |
| 916 | 916 | dot.setAttribute('fill', '#fff'); |
| 917 | 917 | dot.setAttribute('opacity', (rng() * 0.7 + 0.2).toFixed(2)); |
| 918 | 918 | stars.appendChild(dot); |
| 919 | 919 | } |
| 920 | 920 | svg.appendChild(stars); |
| 921 | 921 | |
| 922 | 922 | var glow = document.createElementNS(SVG_NS, 'circle'); |
| 923 | 923 | glow.setAttribute('cx', W/2); glow.setAttribute('cy', H/2); |
| 924 | 924 | glow.setAttribute('fill', 'url(#g-glow)'); |
| 925 | 925 | svg.appendChild(glow); |
| 926 | 926 | |
| 927 | 927 | var sphere = document.createElementNS(SVG_NS, 'circle'); |
| 928 | 928 | sphere.setAttribute('cx', W/2); sphere.setAttribute('cy', H/2); |
| 929 | 929 | sphere.setAttribute('fill', 'url(#g-sphere)'); |
| 930 | 930 | sphere.setAttribute('stroke', 'rgba(160,26,44,0.35)'); |
| 931 | 931 | sphere.setAttribute('stroke-width', '1'); |
| 932 | 932 | svg.appendChild(sphere); |
| 933 | 933 | |
| 934 | 934 | var gGrid = document.createElementNS(SVG_NS, 'g'); |
| 935 | 935 | var gContinents = document.createElementNS(SVG_NS, 'g'); |
| 936 | 936 | var gDots = document.createElementNS(SVG_NS, 'g'); |
| 937 | 937 | svg.appendChild(gGrid); |
| 938 | 938 | svg.appendChild(gContinents); |
| 939 | 939 | svg.appendChild(gDots); |
| 940 | 940 | |
| 941 | 941 | // Hint label gets a translucent background + soft purple border |
| 942 | 942 | // so it reads as a discrete UI chip on top of the sphere instead |
| 943 | 943 | // of just floating text. Rect is appended first so the text |
| 944 | 944 | // paints on top of it. |
| 945 | 945 | var hintBg = document.createElementNS(SVG_NS, 'rect'); |
| 946 | 946 | hintBg.setAttribute('fill', 'rgba(15,23,42,0.72)'); |
| 947 | 947 | hintBg.setAttribute('stroke', 'rgba(160,26,44,0.55)'); |
| 948 | 948 | hintBg.setAttribute('stroke-width', '1'); |
| 949 | 949 | hintBg.setAttribute('rx', '6'); |
| 950 | 950 | hintBg.setAttribute('ry', '6'); |
| 951 | 951 | svg.appendChild(hintBg); |
| 952 | 952 | |
| 953 | 953 | var hint = document.createElementNS(SVG_NS, 'text'); |
| 954 | 954 | hint.setAttribute('x', 18); |
| 955 | 955 | hint.setAttribute('y', 24); |
| 956 | 956 | hint.setAttribute('fill', '#d97a86'); |
| 957 | 957 | hint.setAttribute('font-size', '11'); |
| 958 | 958 | hint.setAttribute('font-family', 'ui-monospace, SFMono-Regular, monospace'); |
| 959 | 959 | hint.setAttribute('letter-spacing', '1'); |
| 960 | 960 | hint.textContent = AUTOSPIN |
| 961 | 961 | ? 'DRAG TO ROTATE - SCROLL TO ZOOM - CLICK A DOT TO DRILL' |
| 962 | 962 | : 'DRAG TO ROTATE - SCROLL OVER GLOBE TO ZOOM - CLICK A DOT TO DRILL'; |
| 963 | 963 | svg.appendChild(hint); |
| 964 | 964 | |
| 965 | 965 | // Size the rect to fit the text once the text is in the DOM. |
| 966 | 966 | // getBBox() works on SVG without a layout pass, but we wrap in |
| 967 | 967 | // a try/catch in case the host isn't visible yet. |
| 968 | 968 | requestAnimationFrame(function () { |
| 969 | 969 | try { |
| 970 | 970 | var bb = hint.getBBox(); |
| 971 | 971 | var padX = 10, padY = 5; |
| 972 | 972 | hintBg.setAttribute('x', (bb.x - padX).toFixed(1)); |
| 973 | 973 | hintBg.setAttribute('y', (bb.y - padY).toFixed(1)); |
| 974 | 974 | hintBg.setAttribute('width', (bb.width + padX * 2).toFixed(1)); |
| 975 | 975 | hintBg.setAttribute('height', (bb.height + padY * 2).toFixed(1)); |
| 976 | 976 | } catch (e) { |
| 977 | 977 | // Fallback approx dimensions if the bbox call fails. |
| 978 | 978 | hintBg.setAttribute('x', 8); |
| 979 | 979 | hintBg.setAttribute('y', 10); |
| 980 | 980 | hintBg.setAttribute('width', 520); |
| 981 | 981 | hintBg.setAttribute('height', 22); |
| 982 | 982 | } |
| 983 | 983 | }); |
| 984 | 984 | |
| 985 | 985 | // ---- Projection / clipping helpers -------------------------------- |
| 986 | 986 | function project(lat, lng) { |
| 987 | 987 | var phi = lat * D2R; |
| 988 | 988 | var lam = (lng - lng0) * D2R; |
| 989 | 989 | var phi0 = lat0 * D2R; |
| 990 | 990 | var sinPhi = Math.sin(phi), cosPhi = Math.cos(phi); |
| 991 | 991 | var sinPhi0 = Math.sin(phi0), cosPhi0 = Math.cos(phi0); |
| 992 | 992 | var cosLam = Math.cos(lam); |
| 993 | 993 | var cosc = sinPhi0 * sinPhi + cosPhi0 * cosPhi * cosLam; |
| 994 | 994 | return { |
| 995 | 995 | x: (W/2) + R * cosPhi * Math.sin(lam), |
| 996 | 996 | y: (H/2) - R * (cosPhi0 * sinPhi - sinPhi0 * cosPhi * cosLam), |
| 997 | 997 | cosc: cosc, |
| 998 | 998 | visible: cosc >= 0 |
| 999 | 999 | }; |
| 1000 | 1000 | } |
| 1001 | 1001 | |
| 1002 | 1002 | // Linearly interpolate between two lat/lng points and find the |
| 1003 | 1003 | // crossing where cosc transitions from visible->hidden (or vice |
| 1004 | 1004 | // versa). Binary search keeps it fast and accurate enough that |
| 1005 | 1005 | // continents and grid lines hug the sphere edge cleanly. |
| 1006 | 1006 | function findHorizon(p1, p2) { |
| 1007 | 1007 | var lo = 0, hi = 1; |
| 1008 | 1008 | for (var i = 0; i < 10; i++) { |
| 1009 | 1009 | var mid = (lo + hi) * 0.5; |
| 1010 | 1010 | var lat = p1[0] + (p2[0] - p1[0]) * mid; |
| 1011 | 1011 | var lng = p1[1] + (p2[1] - p1[1]) * mid; |
| 1012 | 1012 | var v = project(lat, lng).visible; |
| 1013 | 1013 | if (v === project(p1[0], p1[1]).visible) lo = mid; else hi = mid; |
| 1014 | 1014 | } |
| 1015 | 1015 | var t = (lo + hi) * 0.5; |
| 1016 | 1016 | return [ p1[0] + (p2[0] - p1[0]) * t, p1[1] + (p2[1] - p1[1]) * t ]; |
| 1017 | 1017 | } |
| 1018 | 1018 | |
| 1019 | 1019 | // Convert a [lat,lng] polyline into SVG path data, clipping segments |
| 1020 | 1020 | // that cross the horizon. Returns a path string ready for d="...". |
| 1021 | 1021 | function polyToPath(points) { |
| 1022 | 1022 | if (points.length < 2) return ''; |
| 1023 | 1023 | var d = ''; |
| 1024 | 1024 | var prev = points[0]; |
| 1025 | 1025 | var prevP = project(prev[0], prev[1]); |
| 1026 | 1026 | if (prevP.visible) d += 'M' + prevP.x.toFixed(1) + ',' + prevP.y.toFixed(1); |
| 1027 | 1027 | for (var i = 1; i < points.length; i++) { |
| 1028 | 1028 | var cur = points[i]; |
| 1029 | 1029 | var curP = project(cur[0], cur[1]); |
| 1030 | 1030 | if (prevP.visible && curP.visible) { |
| 1031 | 1031 | d += 'L' + curP.x.toFixed(1) + ',' + curP.y.toFixed(1); |
| 1032 | 1032 | } else if (prevP.visible && !curP.visible) { |
| 1033 | 1033 | var bx = findHorizon(prev, cur); |
| 1034 | 1034 | var bP = project(bx[0], bx[1]); |
| 1035 | 1035 | d += 'L' + bP.x.toFixed(1) + ',' + bP.y.toFixed(1); |
| 1036 | 1036 | } else if (!prevP.visible && curP.visible) { |
| 1037 | 1037 | var bx2 = findHorizon(cur, prev); |
| 1038 | 1038 | var b2P = project(bx2[0], bx2[1]); |
| 1039 | 1039 | d += 'M' + b2P.x.toFixed(1) + ',' + b2P.y.toFixed(1); |
| 1040 | 1040 | d += 'L' + curP.x.toFixed(1) + ',' + curP.y.toFixed(1); |
| 1041 | 1041 | } |
| 1042 | 1042 | prev = cur; prevP = curP; |
| 1043 | 1043 | } |
| 1044 | 1044 | return d; |
| 1045 | 1045 | } |
| 1046 | 1046 | |
| 1047 | 1047 | // ---- Render -------------------------------------------------------- |
| 1048 | 1048 | function render() { |
| 1049 | 1049 | sphere.setAttribute('r', R); |
| 1050 | 1050 | glow.setAttribute('r', R * 1.18); |
| 1051 | 1051 | |
| 1052 | 1052 | var gridParts = []; |
| 1053 | 1053 | // Sample meridians/parallels at fine resolution so they curve |
| 1054 | 1054 | // smoothly; horizon clipping handles where they exit the disc. |
| 1055 | 1055 | for (var mLng = -180; mLng < 180; mLng += 30) { |
| 1056 | 1056 | var meridian = []; |
| 1057 | 1057 | for (var lat = -90; lat <= 90; lat += 3) meridian.push([lat, mLng]); |
| 1058 | 1058 | var p = polyToPath(meridian); |
| 1059 | 1059 | if (p) gridParts.push(p); |
| 1060 | 1060 | } |
| 1061 | 1061 | for (var pLat = -60; pLat <= 60; pLat += 30) { |
| 1062 | 1062 | var parallel = []; |
| 1063 | 1063 | for (var lng = -180; lng <= 180; lng += 3) parallel.push([pLat, lng]); |
| 1064 | 1064 | var p2 = polyToPath(parallel); |
| 1065 | 1065 | if (p2) gridParts.push(p2); |
| 1066 | 1066 | } |
| 1067 | 1067 | gGrid.innerHTML = '<path d="' + gridParts.join(' ') + '" fill="none" stroke="rgba(96,165,250,0.18)" stroke-width="0.8"/>'; |
| 1068 | 1068 | |
| 1069 | 1069 | var contParts = []; |
| 1070 | 1070 | for (var c = 0; c < CONTINENTS_DENSE.length; c++) { |
| 1071 | 1071 | var poly = CONTINENTS_DENSE[c]; |
| 1072 | 1072 | // Close the ring so the clipped path also closes back to its start. |
| 1073 | 1073 | var closed = poly.concat([poly[0]]); |
| 1074 | 1074 | var p3 = polyToPath(closed); |
| 1075 | 1075 | if (p3) contParts.push(p3); |
| 1076 | 1076 | } |
| 1077 | 1077 | |
| 1078 | 1078 | // Internal political borders (polylines, not closed rings). |
| 1079 | 1079 | var borderParts = []; |
| 1080 | 1080 | for (var bj = 0; bj < COUNTRY_BORDERS_DENSE.length; bj++) { |
| 1081 | 1081 | var bp = polyToPath(COUNTRY_BORDERS_DENSE[bj]); |
| 1082 | 1082 | if (bp) borderParts.push(bp); |
| 1083 | 1083 | } |
| 1084 | 1084 | |
| 1085 | 1085 | // Stroke-only continents. Filling produced artifact triangles |
| 1086 | 1086 | // when a polygon spans the horizon -- the auto-close in SVG path |
| 1087 | 1087 | // fill connected exit/entry points across continent interiors. |
| 1088 | 1088 | // With stroke-only we get clean coastline outlines on the sphere, |
| 1089 | 1089 | // and the radial sphere gradient still reads as "land vs ocean". |
| 1090 | 1090 | // Borders render in a softer color/weight beneath so coastlines |
| 1091 | 1091 | // stay the dominant outline. |
| 1092 | 1092 | gContinents.innerHTML = |
| 1093 | 1093 | '<path d="' + borderParts.join(' ') + '" fill="none" ' + |
| 1094 | 1094 | 'stroke="rgba(167,139,250,0.45)" stroke-width="0.8" ' + |
| 1095 | 1095 | 'stroke-dasharray="3 2" stroke-linejoin="round" stroke-linecap="round"/>' + |
| 1096 | 1096 | '<path d="' + contParts.join(' ') + '" fill="none" ' + |
| 1097 | 1097 | 'stroke="rgba(167,139,250,0.85)" stroke-width="1.2" ' + |
| 1098 | 1098 | 'stroke-linejoin="round" stroke-linecap="round"/>'; |
| 1099 | 1099 | |
| 1100 | 1100 | // Subdivision labels (US states, Canadian provinces, Mexican |
| 1101 | 1101 | // states, German lander, Australian states, UK nations, Brazilian |
| 1102 | 1102 | // states, Indian states, Japanese regions) appear once the view |
| 1103 | 1103 | // is zoomed in enough that the parent country's single dot would |
| 1104 | 1104 | // crowd the geography. Threshold: sphere radius > 0.85 x canvas |
| 1105 | 1105 | // baseline -- "country fills most of the disc." |
| 1106 | 1106 | // |
| 1107 | 1107 | // Server-side per-region traffic is currently US-only via |
| 1108 | 1108 | // window.GLOBE_STATE_DATA.US -- non-US subdivisions render as |
| 1109 | 1109 | // geography labels (no count). When tracking_sessions.region |
| 1110 | 1110 | // gets backfilled for other countries the data just slots in. |
| 1111 | 1111 | // Track which parent countries had subdivisions rendered so the |
| 1112 | 1112 | // country-dot loop below knows to skip those duplicate markers. |
| 1113 | 1113 | var stateZoom = R > Math.min(W, H) * 0.85; |
| 1114 | 1114 | var dotsHtml = ''; |
| 1115 | 1115 | var subDivCountriesRendered = {}; |
| 1116 | 1116 | |
| 1117 | 1117 | if (stateZoom) { |
| 1118 | 1118 | for (var subCode in SUBDIVISIONS) { |
| 1119 | 1119 | if (!SUBDIVISIONS.hasOwnProperty(subCode)) continue; |
| 1120 | 1120 | var subList = SUBDIVISIONS[subCode]; |
| 1121 | 1121 | // Per-country traffic counts (server-side data; non-US still |
| 1122 | 1122 | // gets the labels for free even without counts). |
| 1123 | 1123 | var subCounts = (window.GLOBE_STATE_DATA && window.GLOBE_STATE_DATA[subCode]) || {}; |
| 1124 | 1124 | // Busiest subdivision drives relative dot sizing. |
| 1125 | 1125 | var subMax = 0; |
| 1126 | 1126 | for (var skey in subCounts) { |
| 1127 | 1127 | if (subCounts.hasOwnProperty(skey) && subCounts[skey] > subMax) subMax = subCounts[skey]; |
| 1128 | 1128 | } |
| 1129 | 1129 | if (subMax < 1) subMax = 1; |
| 1130 | 1130 | |
| 1131 | 1131 | // Cheap visibility pre-check: project the parent country |
| 1132 | 1132 | // centroid; if its centroid is on the far side of the |
| 1133 | 1133 | // sphere, skip iterating the whole subdivision list for it |
| 1134 | 1134 | // (saves a few hundred projects per frame at high zoom). |
| 1135 | 1135 | var parent = null; |
| 1136 | 1136 | for (var pci = 0; pci < COUNTRY_CENTROIDS.length; pci++) { |
| 1137 | 1137 | if (COUNTRY_CENTROIDS[pci].code === subCode) { parent = COUNTRY_CENTROIDS[pci]; break; } |
| 1138 | 1138 | } |
| 1139 | 1139 | if (parent) { |
| 1140 | 1140 | var parentProj = project(parent.lat, parent.lng); |
| 1141 | 1141 | if (!parentProj.visible) continue; |
| 1142 | 1142 | } |
| 1143 | 1143 | subDivCountriesRendered[subCode] = 1; |
| 1144 | 1144 | |
| 1145 | 1145 | for (var si = 0; si < subList.length; si++) { |
| 1146 | 1146 | var st = subList[si]; |
| 1147 | 1147 | var sp = project(st.lat, st.lng); |
| 1148 | 1148 | if (!sp.visible) continue; |
| 1149 | 1149 | var alphaS = Math.max(0.45, Math.min(1, sp.cosc * 1.6)); |
| 1150 | 1150 | var stCount = subCounts[st.name] || 0; |
| 1151 | 1151 | // Active subdivisions (with traffic) get a bigger purple |
| 1152 | 1152 | // dot + count appended to the abbreviation. Inactive ones |
| 1153 | 1153 | // render as just the abbreviation -- geography label, no |
| 1154 | 1154 | // dot, so the canvas stays uncluttered when zero-data. |
| 1155 | 1155 | var active = stCount > 0; |
| 1156 | 1156 | var srRatio = active ? Math.sqrt(stCount / subMax) : 0; |
| 1157 | 1157 | var sr = active ? (3.5 + srRatio * 8) : 0; |
| 1158 | 1158 | var labelText = active ? (st.code + ' · ' + stCount) : st.code; |
| 1159 | 1159 | var labelColor = active ? '#fff' : 'rgba(203,213,225,0.92)'; |
| 1160 | 1160 | var labelX = sp.x + (active ? (sr + 3) : 0); |
| 1161 | 1161 | dotsHtml += |
| 1162 | 1162 | '<g class="globe-country" data-code="' + subCode + '" data-region="' + st.name + '" ' + |
| 1163 | 1163 | 'data-region-code="' + st.code + '" ' + |
| 1164 | 1164 | 'data-name="' + st.name + '" data-count="' + stCount + '">'; |
| 1165 | 1165 | if (active) { |
| 1166 | 1166 | dotsHtml += |
| 1167 | 1167 | '<circle cx="' + sp.x.toFixed(1) + '" cy="' + sp.y.toFixed(1) + '" r="' + sr.toFixed(1) + '" ' + |
| 1168 | 1168 | 'fill="rgba(160,26,44,' + (alphaS * 0.7).toFixed(2) + ')" ' + |
| 1169 | 1169 | 'stroke="rgba(167,139,250,' + alphaS.toFixed(2) + ')" stroke-width="1" ' + |
| 1170 | 1170 | 'style="cursor:pointer"/>'; |
| 1171 | 1171 | } |
| 1172 | 1172 | dotsHtml += |
| 1173 | 1173 | '<text x="' + labelX.toFixed(1) + '" y="' + (sp.y + 4).toFixed(1) + '" ' + |
| 1174 | 1174 | 'fill="' + labelColor + '" font-size="11" font-weight="700" ' + |
| 1175 | 1175 | 'font-family="ui-monospace, SFMono-Regular, monospace" ' + |
| 1176 | 1176 | (active ? '' : 'text-anchor="middle" ') + |
| 1177 | 1177 | 'pointer-events="none" ' + |
| 1178 | 1178 | 'style="paint-order:stroke;stroke:rgba(3,8,31,0.9);stroke-width:3px">' + |
| 1179 | 1179 | labelText + '</text>' + |
| 1180 | 1180 | '</g>'; |
| 1181 | 1181 | } |
| 1182 | 1182 | } |
| 1183 | 1183 | } |
| 1184 | 1184 | |
| 1185 | 1185 | // Country dots + labels. Strategy: |
| 1186 | 1186 | // - Every entry in COUNTRY_CENTROIDS gets a *label* so the user |
| 1187 | 1187 | // can identify the geography even on a fresh install with no |
| 1188 | 1188 | // traffic yet. |
| 1189 | 1189 | // - Only countries that appear in the traffic feed get a *dot* |
| 1190 | 1190 | // and a count appended to the label. |
| 1191 | 1191 | // - Top-N traffic countries render first so they win placement |
| 1192 | 1192 | // priority in overlapping clusters. |
| 1193 | 1193 | // - Other countries (no traffic) render last in a dimmer style. |
| 1194 | 1194 | // |
| 1195 | 1195 | // window.GLOBE_DATA.countries -> map by code for O(1) lookup |
| 1196 | 1196 | var traffMap = {}; |
| 1197 | 1197 | for (var ci = 0; ci < countries.length; ci++) { |
| 1198 | 1198 | traffMap[countries[ci].code] = countries[ci]; |
| 1199 | 1199 | } |
| 1200 | 1200 | var ranked = countries.slice().sort(function (a, b) { return (b.count||0) - (a.count||0); }); |
| 1201 | 1201 | |
| 1202 | 1202 | // Build the render order: traffic countries first (sorted by |
| 1203 | 1203 | // count desc), then every other country from the centroid table. |
| 1204 | 1204 | var seenCode = {}; |
| 1205 | 1205 | var renderOrder = []; |
| 1206 | 1206 | for (var ri = 0; ri < ranked.length; ri++) { |
| 1207 | 1207 | renderOrder.push({ code: ranked[ri].code, name: ranked[ri].name, |
| 1208 | 1208 | lat: ranked[ri].lat, lng: ranked[ri].lng, |
| 1209 | 1209 | count: ranked[ri].count, hasTraffic: true }); |
| 1210 | 1210 | seenCode[ranked[ri].code] = 1; |
| 1211 | 1211 | } |
| 1212 | 1212 | for (var oi = 0; oi < COUNTRY_CENTROIDS.length; oi++) { |
| 1213 | 1213 | var oc = COUNTRY_CENTROIDS[oi]; |
| 1214 | 1214 | if (seenCode[oc.code]) continue; |
| 1215 | 1215 | renderOrder.push({ code: oc.code, name: oc.name, |
| 1216 | 1216 | lat: oc.lat, lng: oc.lng, |
| 1217 | 1217 | count: 0, hasTraffic: false }); |
| 1218 | 1218 | } |
| 1219 | 1219 | |
| 1220 | 1220 | var placed = []; // [{x1,y1,x2,y2}, ...] |
| 1221 | 1221 | function overlaps(box) { |
| 1222 | 1222 | for (var i = 0; i < placed.length; i++) { |
| 1223 | 1223 | var p = placed[i]; |
| 1224 | 1224 | if (!(box.x2 < p.x1 || box.x1 > p.x2 || box.y2 < p.y1 || box.y1 > p.y2)) return true; |
| 1225 | 1225 | } |
| 1226 | 1226 | return false; |
| 1227 | 1227 | } |
| 1228 | 1228 | |
| 1229 | 1229 | for (var d = 0; d < renderOrder.length; d++) { |
| 1230 | 1230 | var cdata = renderOrder[d]; |
| 1231 | 1231 | var dp = project(cdata.lat, cdata.lng); |
| 1232 | 1232 | if (!dp.visible) continue; |
| 1233 | 1233 | var alpha = Math.max(0.4, Math.min(1, dp.cosc * 1.4)); |
| 1234 | 1234 | var name = (cdata.name || '').replace(/"/g, '"').replace(/</g,'<'); |
| 1235 | 1235 | |
| 1236 | 1236 | // When zoomed in enough that this country's state/region dots |
| 1237 | 1237 | // are rendering, hide the country-level dot AND label entirely. |
| 1238 | 1238 | // The state dots already show where the traffic sits; the |
| 1239 | 1239 | // country dot at that point just covers one state (e.g. the |
| 1240 | 1240 | // US centroid covers KS) and confuses the read. For overall |
| 1241 | 1241 | // country totals the user can zoom out and click the dot. |
| 1242 | 1242 | if (stateZoom && subDivCountriesRendered[cdata.code]) continue; |
| 1243 | 1243 | |
| 1244 | 1244 | var hasTraffic = cdata.hasTraffic && (cdata.count || 0) > 0; |
| 1245 | 1245 | var ratio = hasTraffic ? Math.sqrt((cdata.count || 0) / maxCount) : 0; |
| 1246 | 1246 | var rdot = hasTraffic ? (4 + ratio * 14) : 0; |
| 1247 | 1247 | |
| 1248 | 1248 | dotsHtml += |
| 1249 | 1249 | '<g class="globe-country" data-code="' + cdata.code + '" ' + |
| 1250 | 1250 | 'data-name="' + name + '" data-count="' + (cdata.count || 0) + '" ' + |
| 1251 | 1251 | 'data-x="' + dp.x.toFixed(1) + '" data-y="' + dp.y.toFixed(1) + '">'; |
| 1252 | 1252 | |
| 1253 | 1253 | if (hasTraffic) { |
| 1254 | 1254 | dotsHtml += |
| 1255 | 1255 | '<circle cx="' + dp.x.toFixed(1) + '" cy="' + dp.y.toFixed(1) + '" ' + |
| 1256 | 1256 | 'r="' + rdot.toFixed(1) + '" ' + |
| 1257 | 1257 | 'fill="rgba(160,26,44,' + (alpha * 0.65).toFixed(2) + ')" ' + |
| 1258 | 1258 | 'stroke="rgba(167,139,250,' + alpha.toFixed(2) + ')" stroke-width="1.5" ' + |
| 1259 | 1259 | 'style="cursor:pointer"/>'; |
| 1260 | 1260 | } |
| 1261 | 1261 | |
| 1262 | 1262 | // Label: "Name · count" for traffic countries, plain "Name" |
| 1263 | 1263 | // for the rest (dimmer + smaller). Skip if it'd overlap an |
| 1264 | 1264 | // already-placed label. |
| 1265 | 1265 | var labelTxt = hasTraffic ? (cdata.name + ' · ' + cdata.count) : cdata.name; |
| 1266 | 1266 | var labelX = dp.x + (hasTraffic ? rdot + 5 : 7); |
| 1267 | 1267 | var labelY = dp.y + 4; |
| 1268 | 1268 | var approxW = labelTxt.length * 6.2 + 4; |
| 1269 | 1269 | var labelBox = { x1: labelX - 2, y1: labelY - 11, x2: labelX + approxW, y2: labelY + 3 }; |
| 1270 | 1270 | if (!overlaps(labelBox)) { |
| 1271 | 1271 | placed.push(labelBox); |
| 1272 | 1272 | var labelColor = hasTraffic ? '#fff' : 'rgba(203,213,225,0.92)'; |
| 1273 | 1273 | var fontSize = hasTraffic ? '11' : '10'; |
| 1274 | 1274 | dotsHtml += |
| 1275 | 1275 | '<text x="' + labelX.toFixed(1) + '" y="' + labelY.toFixed(1) + '" ' + |
| 1276 | 1276 | 'fill="' + labelColor + '" font-size="' + fontSize + '" font-weight="700" ' + |
| 1277 | 1277 | 'font-family="ui-monospace, SFMono-Regular, monospace" ' + |
| 1278 | 1278 | 'pointer-events="none" ' + |
| 1279 | 1279 | 'style="paint-order:stroke;stroke:rgba(3,8,31,0.92);stroke-width:3.5px">' + |
| 1280 | 1280 | labelTxt + '</text>'; |
| 1281 | 1281 | } |
| 1282 | 1282 | dotsHtml += '</g>'; |
| 1283 | 1283 | } |
| 1284 | 1284 | gDots.innerHTML = dotsHtml; |
| 1285 | 1285 | } |
| 1286 | 1286 | |
| 1287 | 1287 | // ---- Interaction (sets state + dirty; rAF renders) ------------------ |
| 1288 | 1288 | var dragging = false; |
| 1289 | 1289 | var dragStart = null; |
| 1290 | 1290 | var dragMoved = false; // true once the pointer moved past a threshold |
| 1291 | 1291 | var lastInteraction = performance.now(); |
| 1292 | 1292 | |
| 1293 | 1293 | function getPoint(e) { |
| 1294 | 1294 | if (e.touches && e.touches.length) return { x: e.touches[0].clientX, y: e.touches[0].clientY }; |
| 1295 | 1295 | return { x: e.clientX, y: e.clientY }; |
| 1296 | 1296 | } |
| 1297 | 1297 | |
| 1298 | 1298 | // Two-finger pinch state. Active only while exactly 2 touches are down. |
| 1299 | 1299 | var pinching = false; |
| 1300 | 1300 | var pinchStartDist = 0; |
| 1301 | 1301 | var pinchStartR = 0; |
| 1302 | 1302 | function touchDistance(e) { |
| 1303 | 1303 | if (!e.touches || e.touches.length < 2) return 0; |
| 1304 | 1304 | var dx = e.touches[0].clientX - e.touches[1].clientX; |
| 1305 | 1305 | var dy = e.touches[0].clientY - e.touches[1].clientY; |
| 1306 | 1306 | return Math.sqrt(dx*dx + dy*dy); |
| 1307 | 1307 | } |
| 1308 | 1308 | |
| 1309 | 1309 | function onDown(e) { |
| 1310 | 1310 | // Two-finger touch -> pinch-to-zoom. Take precedence over drag. |
| 1311 | 1311 | if (e.touches && e.touches.length >= 2) { |
| 1312 | 1312 | pinching = true; |
| 1313 | 1313 | dragging = false; |
| 1314 | 1314 | pinchStartDist = touchDistance(e); |
| 1315 | 1315 | pinchStartR = Rtarget; |
| 1316 | 1316 | lastInteraction = performance.now(); |
| 1317 | 1317 | e.preventDefault(); |
| 1318 | 1318 | return; |
| 1319 | 1319 | } |
| 1320 | 1320 | // Reset the drag-moved flag on every mousedown so a stale flag |
| 1321 | 1321 | // from a previous drag doesn't swallow the next click on a |
| 1322 | 1322 | // country/state dot. |
| 1323 | 1323 | dragMoved = false; |
| 1324 | 1324 | // Don't start a drag on a country dot -- those need to receive |
| 1325 | 1325 | // the click cleanly so the drill-down navigates. |
| 1326 | 1326 | if (e.target && e.target.closest && e.target.closest('.globe-country')) return; |
| 1327 | 1327 | dragging = true; |
| 1328 | 1328 | var p = getPoint(e); |
| 1329 | 1329 | dragStart = { x: p.x, y: p.y, lat0: lat0, lng0: lng0 }; |
| 1330 | 1330 | svg.style.cursor = 'grabbing'; |
| 1331 | 1331 | lastInteraction = performance.now(); |
| 1332 | 1332 | e.preventDefault(); |
| 1333 | 1333 | } |
| 1334 | 1334 | function onMove(e) { |
| 1335 | 1335 | // Pinch wins over drag while 2 fingers are on screen. |
| 1336 | 1336 | if (pinching && e.touches && e.touches.length >= 2) { |
| 1337 | 1337 | var d = touchDistance(e); |
| 1338 | 1338 | if (d > 0 && pinchStartDist > 0) { |
| 1339 | 1339 | var minR = Math.min(W, H) * 0.18; |
| 1340 | 1340 | var maxR = Math.min(W, H) * 5.0; |
| 1341 | 1341 | var scale = d / pinchStartDist; |
| 1342 | 1342 | Rtarget = Math.max(minR, Math.min(maxR, pinchStartR * scale)); |
| 1343 | 1343 | lastInteraction = performance.now(); |
| 1344 | 1344 | dirty = true; |
| 1345 | 1345 | } |
| 1346 | 1346 | e.preventDefault(); |
| 1347 | 1347 | return; |
| 1348 | 1348 | } |
| 1349 | 1349 | if (!dragging) return; |
| 1350 | 1350 | var p = getPoint(e); |
| 1351 | 1351 | var dx = p.x - dragStart.x; |
| 1352 | 1352 | var dy = p.y - dragStart.y; |
| 1353 | 1353 | // 4px threshold before we consider it a "real" drag. Any click |
| 1354 | 1354 | // landing after a real drag is swallowed by the click handler. |
| 1355 | 1355 | if (!dragMoved && (dx*dx + dy*dy) > 16) dragMoved = true; |
| 1356 | 1356 | var sens = 0.32; |
| 1357 | 1357 | lng0 = dragStart.lng0 - dx * sens; |
| 1358 | 1358 | while (lng0 > 180) lng0 -= 360; |
| 1359 | 1359 | while (lng0 < -180) lng0 += 360; |
| 1360 | 1360 | lat0 = Math.max(-85, Math.min(85, dragStart.lat0 + dy * sens)); |
| 1361 | 1361 | lastInteraction = performance.now(); |
| 1362 | 1362 | dirty = true; |
| 1363 | 1363 | // Touch drag: stop the page from scrolling underneath. |
| 1364 | 1364 | if (e.touches) e.preventDefault(); |
| 1365 | 1365 | } |
| 1366 | 1366 | function onUp(e) { |
| 1367 | 1367 | // Coming out of pinch -- if one finger remains, hand control back |
| 1368 | 1368 | // to a fresh single-finger drag from the remaining touch position. |
| 1369 | 1369 | if (pinching) { |
| 1370 | 1370 | pinching = false; |
| 1371 | 1371 | if (e && e.touches && e.touches.length === 1) { |
| 1372 | 1372 | var p = { x: e.touches[0].clientX, y: e.touches[0].clientY }; |
| 1373 | 1373 | dragging = true; |
| 1374 | 1374 | dragMoved = false; |
| 1375 | 1375 | dragStart = { x: p.x, y: p.y, lat0: lat0, lng0: lng0 }; |
| 1376 | 1376 | } |
| 1377 | 1377 | return; |
| 1378 | 1378 | } |
| 1379 | 1379 | if (dragging) { dragging = false; svg.style.cursor = 'grab'; } |
| 1380 | 1380 | } |
| 1381 | 1381 | |
| 1382 | 1382 | svg.addEventListener('mousedown', onDown); |
| 1383 | 1383 | window.addEventListener('mousemove', onMove); |
| 1384 | 1384 | window.addEventListener('mouseup', onUp); |
| 1385 | 1385 | svg.addEventListener('touchstart', onDown, { passive: false }); |
| 1386 | 1386 | window.addEventListener('touchmove', onMove, { passive: false }); |
| 1387 | 1387 | window.addEventListener('touchend', onUp); |
| 1388 | 1388 | |
| 1389 | 1389 | function onWheel(e) { |
| 1390 | 1390 | e.preventDefault(); |
| 1391 | 1391 | e.stopPropagation(); |
| 1392 | 1392 | var minR = Math.min(W, H) * 0.18; |
| 1393 | 1393 | // Allow zooming WELL past "sphere fills the canvas" -- once the |
| 1394 | 1394 | // sphere is bigger than the viewport you can pan around like |
| 1395 | 1395 | // dragging on a giant globe, which makes state/province labels |
| 1396 | 1396 | // legible. Bumped to 5x so the seller can zoom right down to a |
| 1397 | 1397 | // single country's subdivisions and still read every label. |
| 1398 | 1398 | var maxR = Math.min(W, H) * 5.0; |
| 1399 | 1399 | Rtarget = Math.max(minR, Math.min(maxR, Rtarget * (1 - e.deltaY * 0.0018))); |
| 1400 | 1400 | lastInteraction = performance.now(); |
| 1401 | 1401 | } |
| 1402 | 1402 | svg.addEventListener('wheel', onWheel, { passive: false }); |
| 1403 | 1403 | |
| 1404 | 1404 | svg.addEventListener('click', function (e) { |
| 1405 | 1405 | if (dragMoved) { dragMoved = false; return; } |
| 1406 | 1406 | var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null; |
| 1407 | 1407 | if (!g) return; |
| 1408 | 1408 | var code = g.getAttribute('data-code'); |
| 1409 | 1409 | if (!code) return; |
| 1410 | 1410 | // data-region is set for US-state dots only. Country dots have no |
| 1411 | 1411 | // region attribute, so country-level detail is loaded by default. |
| 1412 | 1412 | var region = g.getAttribute('data-region') || ''; |
| 1413 | 1413 | openDrillModal(code, region); |
| 1414 | 1414 | }); |
| 1415 | 1415 | |
| 1416 | 1416 | // Document-level delegate for the "Top countries" leaderboard links |
| 1417 | 1417 | // (and anywhere else that opts in). Marking an element with |
| 1418 | 1418 | // data-country-drill="XX" routes its click through the modal |
| 1419 | 1419 | // instead of navigating. We attach once per page; multiple globe |
| 1420 | 1420 | // hosts share a single modal so this never double-fires. |
| 1421 | 1421 | if (!window.__globeDrillBound) { |
| 1422 | 1422 | window.__globeDrillBound = true; |
| 1423 | 1423 | document.addEventListener('click', function (e) { |
| 1424 | 1424 | var t = e.target && e.target.closest ? e.target.closest('[data-country-drill]') : null; |
| 1425 | 1425 | if (!t) return; |
| 1426 | 1426 | e.preventDefault(); |
| 1427 | 1427 | var code = t.getAttribute('data-country-drill') || ''; |
| 1428 | 1428 | var region = t.getAttribute('data-region') || ''; |
| 1429 | 1429 | if (code) openDrillModal(code, region); |
| 1430 | 1430 | }); |
| 1431 | 1431 | } |
| 1432 | 1432 | |
| 1433 | 1433 | // Custom tooltip on hover. |
| 1434 | 1434 | svg.addEventListener('mousemove', function (e) { |
| 1435 | 1435 | var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null; |
| 1436 | 1436 | if (!g) { tip.style.opacity = '0'; return; } |
| 1437 | 1437 | var rect = host.getBoundingClientRect(); |
| 1438 | 1438 | var name = g.getAttribute('data-name') || ''; |
| 1439 | 1439 | var n = g.getAttribute('data-count') || '0'; |
| 1440 | 1440 | tip.innerHTML = name + ' · <span style="color:#d97a86">' + n + '</span> visitors'; |
| 1441 | 1441 | tip.style.left = (e.clientX - rect.left) + 'px'; |
| 1442 | 1442 | tip.style.top = (e.clientY - rect.top) + 'px'; |
| 1443 | 1443 | tip.style.opacity = '1'; |
| 1444 | 1444 | }); |
| 1445 | 1445 | svg.addEventListener('mouseleave', function () { tip.style.opacity = '0'; }); |
| 1446 | 1446 | |
| 1447 | 1447 | // ---- rAF loop: smooth zoom easing + idle spin + render-on-dirty ----- |
| 1448 | 1448 | var lastSavedLat = lat0, lastSavedLng = lng0, lastSavedR = R; |
| 1449 | 1449 | function tick(now) { |
| 1450 | 1450 | if (Math.abs(R - Rtarget) > 0.2) { |
| 1451 | 1451 | R += (Rtarget - R) * 0.22; |
| 1452 | 1452 | dirty = true; |
| 1453 | 1453 | } |
| 1454 | 1454 | if (AUTOSPIN && !dragging && (now - lastInteraction) > 3000) { |
| 1455 | 1455 | lng0 -= 0.12; |
| 1456 | 1456 | if (lng0 < -180) lng0 += 360; |
| 1457 | 1457 | dirty = true; |
| 1458 | 1458 | } |
| 1459 | 1459 | if (dirty) { render(); dirty = false; } |
| 1460 | 1460 | // Persist state only when the user has actually moved something |
| 1461 | 1461 | // (drag finished, zoom settled, etc). Compared against the last |
| 1462 | 1462 | // saved snapshot so we don't write to storage every frame during |
| 1463 | 1463 | // a drag -- writes are cheap but not free. |
| 1464 | 1464 | if (!dragging && |
| 1465 | 1465 | (Math.abs(lat0 - lastSavedLat) > 0.5 || |
| 1466 | 1466 | Math.abs(lng0 - lastSavedLng) > 0.5 || |
| 1467 | 1467 | Math.abs(R - lastSavedR) > 1)) { |
| 1468 | 1468 | lastSavedLat = lat0; lastSavedLng = lng0; lastSavedR = R; |
| 1469 | 1469 | saveState(); |
| 1470 | 1470 | } |
| 1471 | 1471 | requestAnimationFrame(tick); |
| 1472 | 1472 | } |
| 1473 | 1473 | requestAnimationFrame(tick); |
| 1474 | 1474 | } |
| 1475 | 1475 | |
| 1476 | 1476 | if (document.readyState === 'loading') { |
| 1477 | 1477 | document.addEventListener('DOMContentLoaded', initGlobes); |
| 1478 | 1478 | } else { |
| 1479 | 1479 | initGlobes(); |
| 1480 | 1480 | } |
| 1481 | 1481 | })(); |