Diff -- /var/www/vhosts/3dshawn.com/shop.3dshawn.com/assets/javascript/globe.js

O Operator
Diff

/var/www/vhosts/3dshawn.com/shop.3dshawn.com/assets/javascript/globe.js

added on local at 2026-07-06 17:40:39

Added
+1481
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 9806e2caf3aa
Restore this content →
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(99,102,241,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(99,102,241,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(99,102,241,0.15);' +
765 'border:1px solid rgba(199,210,254,0.9);color:#fff;border-radius:8px;' +
766 'width:32px;height:32px;cursor:pointer;font-size:18px;line-height:1;display:grid;' +
767 'place-items:center">&times;</button>' +
768 '<div data-globe-modal-body><div style="padding:48px;text-align:center;color:var(--col-text-3,#94a3b8)">Loading&hellip;</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&hellip;</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 += '&region=' + 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) + ' &middot; <span style="color:#a5b4fc">' + 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="#1e1b4b"/>' +
898 '<stop offset="55%" stop-color="#312e81"/>' +
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(99,102,241,0)"/>' +
903 '<stop offset="90%" stop-color="rgba(99,102,241,0.25)"/>' +
904 '<stop offset="100%" stop-color="rgba(99,102,241,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(99,102,241,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(99,102,241,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', '#a5b4fc');
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(99,102,241,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(99,102,241,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 + ' &middot; ' + stCount) : st.code;
1159 var labelColor = active ? '#fff' : 'rgba(199,210,254,0.9)';
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(99,102,241,' + (alphaS * 0.7).toFixed(2) + ')" ' +
1169 'stroke="rgba(99,102,241,' + 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, '&quot;').replace(/</g,'&lt;');
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(99,102,241,' + (alpha * 0.65).toFixed(2) + ')" ' +
1258 'stroke="rgba(99,102,241,' + 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(199,210,254,0.9)';
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 + ' &middot; <span style="color:#a5b4fc">' + 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})();