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