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

O Operator
Diff

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

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

Added
+0
lines
Removed
-0
lines
Context
1481
unchanged
Blobs
from e6a15088ae4b
to e6a15088ae4b
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
11/*======================================================================
22 * TaskForge - Interactive 3D globe (vanilla JS, no deps)
33 *
44 * Renders a draggable, zoomable orthographic globe into the host
55 * <div data-globe>. Reads country dot data from window.GLOBE_DATA
66 * (set by the host page via an inline <script>) so the same widget
77 * used by the admin Traffic Statistics page.
88 *
99 * Interactions:
1010 * - Mouse / touch drag : rotate (lat0, lng0)
1111 * - Mouse wheel / pinch: zoom (sphere radius)
1212 * - Click a country dot: navigate to host.dataset.drillHref + ?country=XX
1313 * - Hover a country dot: in-canvas tooltip with the visitor count
1414 * - Idle for 3s : slow auto-rotation around the longitude axis
1515 *
1616 * Country dots: { code, name, lat, lng, count }
1717 * Continent shapes: compact lat/lng polygons embedded below. Each
1818 * polygon edge that crosses the visible horizon is clipped via binary
1919 * search so continents fade off cleanly at the edge instead of
2020 * chunking out vertex-by-vertex.
2121 *
2222 * Render is decoupled from input via requestAnimationFrame. Input
2323 * handlers only set state + a dirty flag; the frame loop reads dirty
2424 * and re-renders. This keeps drag motion smooth even when pointer
2525 * events fire faster than the browser can paint.
2626 *====================================================================*/
2727(function () {
2828 'use strict';
2929
3030 // ---- Schematic continent outlines (lat/lng polygons) ----------------
3131 // Each polygon traces a major landmass; coastlines are extended out
3232 // to their real extents (e.g. Somalia at ~51E, the Korean peninsula,
3333 // Kamchatka, etc.) so the visible portion doesn't appear to terminate
3434 // before the actual horizon as you rotate.
3535 var CONTINENTS = [
3636 // North America (Alaska -> Central America)
3737 [[71,-156],[70,-141],[69,-128],[66,-121],[60,-115],[55,-110],[52,-100],
3838 [49,-95],[49,-89],[49,-83],[47,-80],[45,-77],[44,-66],[44,-60],[41,-65],
3939 [41,-70],[37,-76],[32,-79],[30,-81],[27,-80],[25,-81],[25,-83],[22,-85],
4040 [21,-87],[17,-89],[15,-87],[13,-88],[14,-92],[16,-94],[20,-97],[22,-100],
4141 [22,-105],[23,-110],[26,-112],[31,-117],[34,-120],[38,-123],[44,-124],
4242 [48,-124],[52,-128],[55,-130],[58,-134],[60,-140],[63,-145],[66,-152],
4343 [68,-160],[69,-166],[71,-162],[71,-156]],
4444
4545 // Greenland
4646 [[83,-32],[83,-25],[81,-15],[78,-19],[75,-21],[73,-22],[69,-22],[64,-40],
4747 [60,-43],[60,-48],[64,-52],[68,-54],[72,-55],[76,-65],[78,-68],[81,-63],
4848 [83,-55],[83,-50],[83,-40],[83,-32]],
4949
5050 // South America
5151 [[12,-72],[12,-68],[11,-65],[8,-60],[6,-58],[3,-50],[-1,-50],[-3,-44],
5252 [-6,-35],[-10,-36],[-13,-38],[-15,-38],[-18,-39],[-22,-40],[-25,-46],
5353 [-28,-48],[-30,-50],[-34,-52],[-37,-56],[-39,-58],[-43,-62],[-46,-66],
5454 [-50,-69],[-52,-70],[-55,-68],[-54,-70],[-50,-73],[-46,-74],[-43,-74],
5555 [-38,-73],[-35,-72],[-30,-71],[-26,-70],[-22,-70],[-18,-70],[-15,-75],
5656 [-10,-78],[-7,-79],[-5,-81],[0,-80],[3,-78],[5,-77],[8,-77],[10,-76],
5757 [12,-72]],
5858
5959 // Europe (with Iberia, Italy, Scandinavia, eastern reach into Russia)
6060 [[71,28],[71,40],[68,15],[64,5],[60,5],[58,5],[55,7],[55,12],[55,15],
6161 [54,8],[51,2],[49,-5],[46,-2],[44,-9],[40,-9],[37,-9],[36,-5],[36,0],
6262 [38,8],[40,15],[40,18],[40,22],[37,27],[36,27],[40,29],[45,30],[50,35],
6363 [55,40],[55,30],[58,28],[60,28],[65,25],[68,22],[70,25],[71,28]],
6464
6565 // Africa (extended to Horn of Africa ~51E)
6666 [[36,-6],[36,3],[35,11],[33,22],[31,32],[29,34],[25,35],[22,36],[18,38],
6767 [12,43],[11,45],[10,49],[8,50],[5,46],[3,42],[0,42],[-2,41],[-5,40],
6868 [-8,40],[-12,40],[-16,40],[-22,35],[-26,32],[-28,32],[-30,30],[-32,28],
6969 [-34,28],[-34,22],[-34,18],[-31,17],[-28,16],[-23,14],[-20,12],[-15,12],
7070 [-12,13],[-8,12],[-5,9],[-2,9],[2,9],[4,9],[6,3],[5,-1],[5,-3],[6,-8],
7171 [8,-13],[11,-15],[14,-17],[17,-16],[20,-17],[23,-16],[28,-13],[31,-9],
7272 [34,-7],[36,-6]],
7373
7474 // Asia (extends to Kamchatka, Korea, SE Asia)
7575 [[72,55],[75,75],[78,90],[76,105],[74,120],[72,128],[70,140],[67,155],
7676 [67,170],[62,177],[58,172],[55,160],[55,155],[51,156],[48,142],[46,140],
7777 [45,135],[41,132],[38,131],[36,131],[35,129],[34,128],[34,126],[34,124],
7878 [38,121],[39,122],[37,122],[35,118],[33,121],[30,121],[28,121],[24,118],
7979 [22,113],[21,110],[19,108],[17,108],[14,109],[10,107],[8,106],[5,103],
8080 [2,103],[5,101],[7,99],[10,99],[12,98],[16,95],[20,93],[22,90],[22,86],
8181 [19,85],[19,82],[15,80],[12,77],[9,77],[8,80],[11,75],[15,73],[19,72],
8282 [22,69],[23,68],[25,62],[26,62],[27,56],[24,55],[24,52],[22,46],[20,42],
8383 [15,44],[13,44],[15,52],[18,55],[12,55],[20,58],[24,60],[30,57],[35,53],
8484 [38,48],[40,48],[42,48],[44,48],[46,52],[48,55],[51,52],[52,50],[55,45],
8585 [58,40],[60,42],[62,45],[65,42],[68,45],[70,50],[72,55]],
8686
8787 // Australia
8888 [[-11,142],[-11,138],[-13,135],[-15,130],[-18,123],[-20,118],[-22,114],
8989 [-26,114],[-30,115],[-31,115],[-34,118],[-35,121],[-35,123],[-35,127],
9090 [-34,133],[-37,140],[-38,143],[-38,145],[-39,147],[-37,149],[-34,151],
9191 [-32,153],[-28,153],[-25,153],[-22,150],[-19,147],[-15,145],[-13,143],
9292 [-11,142]],
9393
9494 // Antarctica edge
9595 [[-66,-180],[-66,-150],[-66,-120],[-70,-100],[-70,-90],[-72,-70],[-72,-60],
9696 [-75,-45],[-75,-30],[-72,-10],[-72,0],[-70,15],[-70,30],[-72,50],[-72,60],
9797 [-75,75],[-75,90],[-74,105],[-72,120],[-70,135],[-68,150],[-66,165],
9898 [-66,180]],
9999
100100 // British Isles
101101 [[58,-7],[60,-3],[58,-2],[57,-2],[55,-2],[55,-6],[53,-5],[51,-4],[50,-5],
102102 [50,-2],[51,1],[53,1],[54,-1],[57,-3],[58,-7]],
103103
104104 // Iceland
105105 [[66,-24],[67,-19],[66,-14],[64,-14],[63,-19],[63,-22],[64,-24],[66,-24]],
106106
107107 // Japan
108108 [[45,142],[44,143],[42,142],[40,141],[37,141],[35,140],[34,137],[33,135],
109109 [33,131],[32,129],[33,130],[34,132],[36,135],[37,137],[39,140],[42,141],
110110 [45,142]],
111111
112112 // Indonesia
113113 [[5,95],[3,98],[1,100],[-1,102],[-3,103],[-5,105],[-5,107],[-7,109],
114114 [-8,113],[-9,116],[-8,121],[-6,124],[-5,122],[-3,124],[-2,124],[0,122],
115115 [1,121],[2,118],[2,116],[3,113],[4,108],[5,99],[5,95]],
116116
117117 // Madagascar
118118 [[-12,49],[-15,50],[-18,49],[-20,46],[-22,44],[-24,44],[-25,46],[-24,48],
119119 [-22,49],[-19,49],[-15,50],[-12,49]],
120120
121121 // New Zealand
122122 [[-34,173],[-37,175],[-39,178],[-41,174],[-43,172],[-46,167],[-47,170],
123123 [-44,174],[-41,177],[-39,178],[-34,173]]
124124 ];
125125
126126 var D2R = Math.PI / 180;
127127
128128 // Subdivide every edge of a polygon into smaller steps. The raw
129129 // CONTINENTS data only has a few dozen vertices per landmass, which
130130 // means a straight lat/lng line between two faraway points doesn't
131131 // bow the way a real coastline does -- so as you rotate the globe,
132132 // the visible portion appears to terminate well inside the actual
133133 // horizon. Densifying once at init costs a few KB of memory and
134134 // costs nothing per frame.
135135 function densify(points, maxStep) {
136136 var out = [points[0]];
137137 for (var i = 1; i < points.length; i++) {
138138 var p1 = points[i - 1], p2 = points[i];
139139 var dLat = p2[0] - p1[0];
140140 var dLng = p2[1] - p1[1];
141141 // Distance in lat/lng degrees. Good enough as a step trigger;
142142 // we don't need true great-circle distance here.
143143 var dist = Math.sqrt(dLat * dLat + dLng * dLng);
144144 var n = Math.max(1, Math.ceil(dist / maxStep));
145145 for (var j = 1; j <= n; j++) {
146146 var t = j / n;
147147 out.push([ p1[0] + dLat * t, p1[1] + dLng * t ]);
148148 }
149149 }
150150 return out;
151151 }
152152
153153 // Pre-densified continents (one-shot at module load).
154154 var CONTINENTS_DENSE = [];
155155 for (var ci = 0; ci < CONTINENTS.length; ci++) {
156156 CONTINENTS_DENSE.push(densify(CONTINENTS[ci], 2));
157157 }
158158
159159 // Major internal land borders (polylines, NOT closed polygons).
160160 // Coastlines are already drawn by the continent outlines, so these
161161 // are just the political lines that aren't coastlines. Coarse on
162162 // purpose -- we want recognizable boundaries, not survey-grade
163163 // cartography.
164164 var COUNTRY_BORDERS = [
165165 // US / Canada -- 49th parallel + Great Lakes + Maine
166166 [[49,-123],[49,-117],[49,-110],[49,-104],[49,-100],[49,-97],[49,-95],
167167 [48.5,-92],[48,-89],[47.5,-89],[47,-88],[46.5,-84],[46,-84],[45.5,-83.5],
168168 [44,-82.5],[42.5,-82.5],[42,-83],[42,-81],[42.5,-79],[43,-79],
169169 [43.5,-78],[44,-76],[45,-74.5],[45,-71.5],[45.5,-70],[46,-68],
170170 [47,-68],[47,-67],[45.5,-67]],
171171
172172 // US / Mexico -- Pacific to the Gulf
173173 [[32.5,-117.1],[32.7,-114.7],[31.3,-111],[31.3,-108.2],[31.8,-106.5],
174174 [29.8,-104],[29.5,-101.5],[28.7,-100.5],[26.4,-99.2],[26,-97.4]],
175175
176176 // China / Russia -- Amur River + Mongolia top edge
177177 [[49,86],[50,90],[52,100],[53,105],[51,110],[50,115],[50,120],[48,125],
178178 [49,130],[51,134],[53,140]],
179179
180180 // China / India -- Himalayas
181181 [[35,77],[34,79],[33,80],[32,82],[30,86],[28,88],[27,90],[27,92],
182182 [29,96]],
183183
184184 // India / Pakistan
185185 [[35,75],[33,75],[31,75],[29,73],[27,71],[24,69],[24,68]],
186186
187187 // China / Mongolia (south side)
188188 [[42,90],[42,95],[43,100],[43,105],[42,110],[43,115],[44,118]],
189189
190190 // Brazil -- inland boundary with neighbors (roughly traces W + N edge)
191191 [[5,-60],[4,-61],[2,-65],[1,-67],[0,-68],[-2,-70],[-4,-70],[-7,-73],
192192 [-10,-69],[-12,-69],[-15,-60],[-18,-58],[-21,-58],[-23,-58],
193193 [-27,-55],[-30,-57]],
194194
195195 // Argentina / Chile -- Andes spine
196196 [[-22,-67],[-25,-68],[-28,-69],[-32,-70],[-36,-71],[-40,-71.5],
197197 [-45,-72],[-50,-72],[-53,-71]],
198198
199199 // Germany / France / Belgium / Netherlands (rough corridor)
200200 [[50.5,6.4],[50,6],[49.5,6],[49.2,7],[49,7.6],[48.5,8],[47.6,7.6],
201201 [47.5,7],[47.5,6.7]],
202202
203203 // ===== Expanded political dividing lines ===========================
204204
205205 // Spain / Portugal
206206 [[42,-8.5],[41,-7.5],[40,-7],[39,-7.5],[38,-7],[37.2,-7.5]],
207207
208208 // France / Spain (Pyrenees)
209209 [[43.4,-1.5],[42.9,0],[42.7,1.5],[42.4,2.7],[42.4,3.0]],
210210
211211 // France / Italy / Switzerland (Alps corridor)
212212 [[45.9,7.0],[45.5,6.9],[44.8,6.9],[44.2,7.4],[43.8,7.5]],
213213
214214 // Italy / Slovenia / Austria
215215 [[46.5,13.7],[46.4,12.5],[46.5,11.0]],
216216
217217 // Czechia / Poland / Slovakia ridge
218218 [[50.1,14.3],[50.3,16.0],[50.0,18.5],[49.7,20.4],[49.3,22.5]],
219219
220220 // Hungary / Romania
221221 [[48.2,22.1],[47.0,22.0],[46.0,21.2],[44.9,22.8]],
222222
223223 // Greece / Bulgaria
224224 [[41.7,22.9],[41.4,24.0],[41.7,25.3],[41.5,26.1]],
225225
226226 // Norway / Sweden ridge
227227 [[68.5,17.5],[66.5,15.5],[63.5,12.3],[60.5,12.5],[58.9,11.6]],
228228
229229 // Poland / Belarus / Ukraine corridor
230230 [[54.4,23.5],[52.7,23.6],[51.5,23.8],[50.3,23.9],[48.5,22.6]],
231231
232232 // Africa: Algeria / Morocco / Mauritania edge
233233 [[35.2,-2.2],[33.0,-1.8],[30.0,-2.8],[27.7,-8.7],[24.9,-12.0]],
234234
235235 // Africa: Egypt / Sudan / Libya
236236 [[29.5,25.0],[27.0,25.0],[24.0,25.0],[22.0,25.0],[22.0,32.0]],
237237
238238 // Africa: Kenya / Tanzania / Uganda triangle
239239 [[-1.0,30.5],[-1.0,34.0],[-1.0,37.0],[-1.7,40.0],[-4.5,39.2]],
240240
241241 // Africa: Nigeria / Cameroon / Niger boundary
242242 [[13.5,2.3],[13.0,6.5],[13.5,10.0],[13.5,14.2],[10.5,14.5]],
243243
244244 // Africa: South Africa / Botswana / Mozambique
245245 [[-22.0,29.0],[-22.5,22.0],[-25.5,20.0],[-28.5,20.0],[-29.5,29.0],
246246 [-26.5,32.0],[-25.5,32.0]],
247247
248248 // Middle East: Saudi Arabia / Iraq / Jordan / Syria
249249 [[32.5,38.8],[31.5,38.8],[29.8,38.0],[29.0,36.5],[29.5,34.8]],
250250
251251 // Middle East: Saudi Arabia / UAE / Oman
252252 [[24.0,52.0],[23.0,55.0],[22.5,55.5],[21.0,57.0],[19.0,55.0],[17.0,52.5]],
253253
254254 // Iran / Pakistan / Afghanistan corner
255255 [[37.0,61.0],[35.5,61.0],[31.5,61.0],[29.0,62.0],[25.5,63.0]],
256256
257257 // Central Asia: Kazakhstan / Uzbekistan / Turkmenistan
258258 [[42.0,55.0],[42.0,60.0],[42.0,65.0],[41.0,71.0],[40.5,75.0]],
259259
260260 // China / Vietnam / Laos / Cambodia / Thailand
261261 [[22.5,103.0],[22.5,105.0],[22.0,107.5],[20.0,108.0],[17.0,107.0],
262262 [14.5,107.0],[12.5,106.5],[10.5,105.0]],
263263
264264 // Indonesia / Malaysia / Singapore arch
265265 [[3.0,99.5],[2.0,102.0],[1.4,103.8],[1.0,104.5]],
266266
267267 // Russia / Kazakhstan (long northern border)
268268 [[54.5,55.5],[54.0,62.0],[53.5,68.5],[52.0,75.0],[51.0,82.0]]
269269 ];
270270
271271 var COUNTRY_BORDERS_DENSE = [];
272272 for (var bi = 0; bi < COUNTRY_BORDERS.length; bi++) {
273273 COUNTRY_BORDERS_DENSE.push(densify(COUNTRY_BORDERS[bi], 2));
274274 }
275275
276276 // World country centroids -- used to label countries even when no
277277 // traffic has come in from them yet. Mirror of the Perl
278278 // %COUNTRY_CENTROIDS table; centroids are approximate (good enough
279279 // for label placement). Traffic dots come from window.GLOBE_DATA;
280280 // labels are emitted for every entry in this table so the user can
281281 // identify "what country am I looking at?" at a glance.
282282 var COUNTRY_CENTROIDS = [
283283 { code:'US', name:'United States', lat:39.5, lng:-98.5 },
284284 { code:'CA', name:'Canada', lat:56.1, lng:-106.3 },
285285 { code:'MX', name:'Mexico', lat:23.6, lng:-102.5 },
286286 { code:'GT', name:'Guatemala', lat:15.8, lng:-90.2 },
287287 { code:'CR', name:'Costa Rica', lat:9.7, lng:-83.7 },
288288 { code:'PA', name:'Panama', lat:8.5, lng:-80.7 },
289289 { code:'CU', name:'Cuba', lat:21.5, lng:-77.7 },
290290 { code:'DO', name:'Dominican Republic', lat:18.7, lng:-70.1 },
291291 { code:'BR', name:'Brazil', lat:-14.2, lng:-51.9 },
292292 { code:'AR', name:'Argentina', lat:-38.4, lng:-63.6 },
293293 { code:'CL', name:'Chile', lat:-35.6, lng:-71.5 },
294294 { code:'CO', name:'Colombia', lat:4.6, lng:-74.0 },
295295 { code:'PE', name:'Peru', lat:-9.2, lng:-75.0 },
296296 { code:'VE', name:'Venezuela', lat:6.4, lng:-66.6 },
297297 { code:'EC', name:'Ecuador', lat:-1.8, lng:-78.2 },
298298 { code:'UY', name:'Uruguay', lat:-32.5, lng:-55.8 },
299299 { code:'GB', name:'United Kingdom', lat:55.4, lng:-3.4 },
300300 { code:'IE', name:'Ireland', lat:53.4, lng:-8.2 },
301301 { code:'FR', name:'France', lat:46.2, lng:2.2 },
302302 { code:'DE', name:'Germany', lat:51.2, lng:10.4 },
303303 { code:'NL', name:'Netherlands', lat:52.1, lng:5.3 },
304304 { code:'BE', name:'Belgium', lat:50.5, lng:4.5 },
305305 { code:'LU', name:'Luxembourg', lat:49.8, lng:6.1 },
306306 { code:'CH', name:'Switzerland', lat:46.8, lng:8.2 },
307307 { code:'AT', name:'Austria', lat:47.5, lng:14.6 },
308308 { code:'IT', name:'Italy', lat:41.9, lng:12.6 },
309309 { code:'ES', name:'Spain', lat:40.5, lng:-3.7 },
310310 { code:'PT', name:'Portugal', lat:39.4, lng:-8.2 },
311311 { code:'DK', name:'Denmark', lat:56.3, lng:9.5 },
312312 { code:'NO', name:'Norway', lat:60.5, lng:8.5 },
313313 { code:'SE', name:'Sweden', lat:60.1, lng:18.6 },
314314 { code:'FI', name:'Finland', lat:61.9, lng:25.7 },
315315 { code:'PL', name:'Poland', lat:51.9, lng:19.1 },
316316 { code:'CZ', name:'Czechia', lat:49.8, lng:15.5 },
317317 { code:'SK', name:'Slovakia', lat:48.7, lng:19.7 },
318318 { code:'HU', name:'Hungary', lat:47.2, lng:19.5 },
319319 { code:'RO', name:'Romania', lat:45.9, lng:25.0 },
320320 { code:'BG', name:'Bulgaria', lat:42.7, lng:25.5 },
321321 { code:'GR', name:'Greece', lat:39.1, lng:21.8 },
322322 { code:'UA', name:'Ukraine', lat:48.4, lng:31.2 },
323323 { code:'RU', name:'Russia', lat:61.5, lng:105.3 },
324324 { code:'TR', name:'Turkey', lat:38.9, lng:35.2 },
325325 { code:'IL', name:'Israel', lat:31.0, lng:34.9 },
326326 { code:'SA', name:'Saudi Arabia', lat:23.9, lng:45.1 },
327327 { code:'AE', name:'United Arab Emirates',lat:23.4, lng:53.8 },
328328 { code:'IR', name:'Iran', lat:32.4, lng:53.7 },
329329 { code:'PK', name:'Pakistan', lat:30.4, lng:69.3 },
330330 { code:'IN', name:'India', lat:20.6, lng:78.9 },
331331 { code:'BD', name:'Bangladesh', lat:23.7, lng:90.4 },
332332 { code:'LK', name:'Sri Lanka', lat:7.9, lng:80.8 },
333333 { code:'NP', name:'Nepal', lat:28.4, lng:84.1 },
334334 { code:'CN', name:'China', lat:35.9, lng:104.2 },
335335 { code:'JP', name:'Japan', lat:36.2, lng:138.3 },
336336 { code:'KR', name:'South Korea', lat:35.9, lng:127.8 },
337337 { code:'TW', name:'Taiwan', lat:23.7, lng:121.0 },
338338 { code:'HK', name:'Hong Kong', lat:22.4, lng:114.1 },
339339 { code:'SG', name:'Singapore', lat:1.4, lng:103.8 },
340340 { code:'MY', name:'Malaysia', lat:4.2, lng:101.9 },
341341 { code:'TH', name:'Thailand', lat:15.9, lng:100.9 },
342342 { code:'VN', name:'Vietnam', lat:14.1, lng:108.3 },
343343 { code:'PH', name:'Philippines', lat:12.9, lng:121.8 },
344344 { code:'ID', name:'Indonesia', lat:-0.8, lng:113.9 },
345345 { code:'AU', name:'Australia', lat:-25.3, lng:133.8 },
346346 { code:'NZ', name:'New Zealand', lat:-40.9, lng:174.9 },
347347 { code:'ZA', name:'South Africa', lat:-30.6, lng:22.9 },
348348 { code:'NG', name:'Nigeria', lat:9.1, lng:8.7 },
349349 { code:'EG', name:'Egypt', lat:26.8, lng:30.8 },
350350 { code:'MA', name:'Morocco', lat:31.8, lng:-7.1 },
351351 { code:'KE', name:'Kenya', lat:-0.0, lng:37.9 },
352352 { code:'ET', name:'Ethiopia', lat:9.1, lng:40.5 },
353353 { code:'GH', name:'Ghana', lat:7.9, lng:-1.0 },
354354 { code:'DZ', name:'Algeria', lat:28.0, lng:1.7 },
355355 { code:'TN', name:'Tunisia', lat:33.9, lng:9.5 },
356356 // ---- Expanded set --------------------------------------------------
357357 // Caribbean / Central America
358358 { code:'JM', name:'Jamaica', lat:18.1, lng:-77.3 },
359359 { code:'HT', name:'Haiti', lat:18.9, lng:-72.3 },
360360 { code:'PR', name:'Puerto Rico', lat:18.2, lng:-66.6 },
361361 { code:'TT', name:'Trinidad and Tobago', lat:10.7, lng:-61.2 },
362362 { code:'BZ', name:'Belize', lat:17.2, lng:-88.5 },
363363 { code:'HN', name:'Honduras', lat:15.2, lng:-86.2 },
364364 { code:'NI', name:'Nicaragua', lat:12.9, lng:-85.2 },
365365 { code:'SV', name:'El Salvador', lat:13.8, lng:-88.9 },
366366 // South America (more)
367367 { code:'BO', name:'Bolivia', lat:-16.3, lng:-63.6 },
368368 { code:'PY', name:'Paraguay', lat:-23.4, lng:-58.4 },
369369 { code:'GY', name:'Guyana', lat:4.9, lng:-58.9 },
370370 { code:'SR', name:'Suriname', lat:3.9, lng:-56.0 },
371371 // Europe (more)
372372 { code:'IS', name:'Iceland', lat:64.9, lng:-19.0 },
373373 { code:'EE', name:'Estonia', lat:58.6, lng:25.0 },
374374 { code:'LV', name:'Latvia', lat:56.9, lng:24.6 },
375375 { code:'LT', name:'Lithuania', lat:55.2, lng:23.9 },
376376 { code:'BY', name:'Belarus', lat:53.7, lng:27.9 },
377377 { code:'MD', name:'Moldova', lat:47.4, lng:28.4 },
378378 { code:'HR', name:'Croatia', lat:45.1, lng:15.2 },
379379 { code:'SI', name:'Slovenia', lat:46.1, lng:14.9 },
380380 { code:'BA', name:'Bosnia and Herzegovina',lat:43.9,lng:17.7 },
381381 { code:'RS', name:'Serbia', lat:44.0, lng:21.0 },
382382 { code:'AL', name:'Albania', lat:41.2, lng:20.2 },
383383 { code:'MK', name:'North Macedonia', lat:41.6, lng:21.7 },
384384 { code:'ME', name:'Montenegro', lat:42.7, lng:19.4 },
385385 // Middle East (more)
386386 { code:'IQ', name:'Iraq', lat:33.2, lng:43.7 },
387387 { code:'JO', name:'Jordan', lat:31.0, lng:36.0 },
388388 { code:'LB', name:'Lebanon', lat:33.9, lng:35.9 },
389389 { code:'SY', name:'Syria', lat:34.8, lng:38.9 },
390390 { code:'OM', name:'Oman', lat:21.5, lng:55.9 },
391391 { code:'QA', name:'Qatar', lat:25.3, lng:51.2 },
392392 { code:'KW', name:'Kuwait', lat:29.3, lng:47.5 },
393393 { code:'BH', name:'Bahrain', lat:26.0, lng:50.6 },
394394 { code:'YE', name:'Yemen', lat:15.6, lng:48.5 },
395395 { code:'AZ', name:'Azerbaijan', lat:40.1, lng:47.6 },
396396 { code:'GE', name:'Georgia', lat:42.3, lng:43.4 },
397397 { code:'AM', name:'Armenia', lat:40.1, lng:45.0 },
398398 // Central + South Asia (more)
399399 { code:'KZ', name:'Kazakhstan', lat:48.0, lng:66.9 },
400400 { code:'UZ', name:'Uzbekistan', lat:41.4, lng:64.6 },
401401 { code:'TM', name:'Turkmenistan', lat:38.9, lng:59.6 },
402402 { code:'KG', name:'Kyrgyzstan', lat:41.2, lng:74.8 },
403403 { code:'TJ', name:'Tajikistan', lat:38.9, lng:71.3 },
404404 { code:'AF', name:'Afghanistan', lat:33.9, lng:67.7 },
405405 { code:'MN', name:'Mongolia', lat:46.9, lng:103.8 },
406406 // Southeast Asia (more)
407407 { code:'MM', name:'Myanmar', lat:21.9, lng:95.9 },
408408 { code:'KH', name:'Cambodia', lat:12.6, lng:104.9 },
409409 { code:'LA', name:'Laos', lat:19.9, lng:102.5 },
410410 // Africa (more)
411411 { code:'SN', name:'Senegal', lat:14.5, lng:-14.5 },
412412 { code:'CI', name:'Cote d\'Ivoire', lat:7.5, lng:-5.5 },
413413 { code:'CM', name:'Cameroon', lat:7.4, lng:12.4 },
414414 { code:'AO', name:'Angola', lat:-11.2, lng:17.9 },
415415 { code:'MZ', name:'Mozambique', lat:-18.7, lng:35.5 },
416416 { code:'UG', name:'Uganda', lat:1.4, lng:32.3 },
417417 { code:'TZ', name:'Tanzania', lat:-6.4, lng:34.9 },
418418 { code:'ZW', name:'Zimbabwe', lat:-19.0, lng:29.2 },
419419 { code:'MG', name:'Madagascar', lat:-18.8, lng:46.9 },
420420 { code:'RW', name:'Rwanda', lat:-2.0, lng:29.9 },
421421 { code:'ML', name:'Mali', lat:17.6, lng:-4.0 },
422422 { code:'NE', name:'Niger', lat:17.6, lng:8.1 },
423423 { code:'LY', name:'Libya', lat:26.3, lng:17.2 },
424424 { code:'SD', name:'Sudan', lat:12.9, lng:30.2 },
425425 // Oceania
426426 { code:'PG', name:'Papua New Guinea', lat:-6.3, lng:143.9 },
427427 { code:'FJ', name:'Fiji', lat:-17.7, lng:178.1 }
428428 ];
429429
430430 // US state centroids + abbreviations. Used when the user zooms in
431431 // far enough that the country dot would crowd the view -- we drop
432432 // the country dot and show state-level abbreviations instead.
433433 // Centroids are approximate (good enough for label placement).
434434 var US_STATES = [
435435 { code:'AL', name:'Alabama', lat:32.8, lng: -86.8 },
436436 { code:'AK', name:'Alaska', lat:63.6, lng:-152.4 },
437437 { code:'AZ', name:'Arizona', lat:34.3, lng:-111.7 },
438438 { code:'AR', name:'Arkansas', lat:34.9, lng: -92.4 },
439439 { code:'CA', name:'California', lat:37.2, lng:-119.4 },
440440 { code:'CO', name:'Colorado', lat:39.0, lng:-105.5 },
441441 { code:'CT', name:'Connecticut', lat:41.6, lng: -72.7 },
442442 { code:'DE', name:'Delaware', lat:39.0, lng: -75.5 },
443443 { code:'FL', name:'Florida', lat:28.5, lng: -82.4 },
444444 { code:'GA', name:'Georgia', lat:32.6, lng: -83.4 },
445445 { code:'HI', name:'Hawaii', lat:20.6, lng:-157.5 },
446446 { code:'ID', name:'Idaho', lat:44.3, lng:-114.7 },
447447 { code:'IL', name:'Illinois', lat:40.0, lng: -89.2 },
448448 { code:'IN', name:'Indiana', lat:39.9, lng: -86.3 },
449449 { code:'IA', name:'Iowa', lat:42.0, lng: -93.5 },
450450 { code:'KS', name:'Kansas', lat:38.5, lng: -98.4 },
451451 { code:'KY', name:'Kentucky', lat:37.6, lng: -85.0 },
452452 { code:'LA', name:'Louisiana', lat:31.2, lng: -91.9 },
453453 { code:'ME', name:'Maine', lat:45.3, lng: -69.2 },
454454 { code:'MD', name:'Maryland', lat:39.0, lng: -76.7 },
455455 { code:'MA', name:'Massachusetts', lat:42.3, lng: -71.8 },
456456 { code:'MI', name:'Michigan', lat:44.3, lng: -85.4 },
457457 { code:'MN', name:'Minnesota', lat:46.3, lng: -94.3 },
458458 { code:'MS', name:'Mississippi', lat:32.7, lng: -89.7 },
459459 { code:'MO', name:'Missouri', lat:38.4, lng: -92.4 },
460460 { code:'MT', name:'Montana', lat:47.0, lng:-109.6 },
461461 { code:'NE', name:'Nebraska', lat:41.5, lng: -99.8 },
462462 { code:'NV', name:'Nevada', lat:39.3, lng:-116.6 },
463463 { code:'NH', name:'New Hampshire', lat:43.7, lng: -71.6 },
464464 { code:'NJ', name:'New Jersey', lat:40.3, lng: -74.5 },
465465 { code:'NM', name:'New Mexico', lat:34.4, lng:-106.1 },
466466 { code:'NY', name:'New York', lat:42.9, lng: -75.5 },
467467 { code:'NC', name:'North Carolina', lat:35.6, lng: -79.8 },
468468 { code:'ND', name:'North Dakota', lat:47.5, lng:-100.3 },
469469 { code:'OH', name:'Ohio', lat:40.3, lng: -82.8 },
470470 { code:'OK', name:'Oklahoma', lat:35.6, lng: -97.5 },
471471 { code:'OR', name:'Oregon', lat:43.8, lng:-120.6 },
472472 { code:'PA', name:'Pennsylvania', lat:40.6, lng: -77.2 },
473473 { code:'RI', name:'Rhode Island', lat:41.7, lng: -71.5 },
474474 { code:'SC', name:'South Carolina', lat:33.9, lng: -80.9 },
475475 { code:'SD', name:'South Dakota', lat:44.3, lng: -99.4 },
476476 { code:'TN', name:'Tennessee', lat:35.8, lng: -86.7 },
477477 { code:'TX', name:'Texas', lat:31.1, lng: -97.6 },
478478 { code:'UT', name:'Utah', lat:40.2, lng:-111.9 },
479479 { code:'VT', name:'Vermont', lat:44.0, lng: -72.7 },
480480 { code:'VA', name:'Virginia', lat:37.8, lng: -78.2 },
481481 { code:'WA', name:'Washington', lat:47.4, lng:-121.5 },
482482 { code:'WV', name:'West Virginia', lat:38.5, lng: -80.6 },
483483 { code:'WI', name:'Wisconsin', lat:44.3, lng: -89.6 },
484484 { code:'WY', name:'Wyoming', lat:42.8, lng:-107.3 },
485485 { code:'DC', name:'District of Columbia', lat:38.9, lng: -77.0 }
486486 ];
487487
488488 // ===== Per-country subdivisions ======================================
489489 // Map keyed by ISO country code. Each entry: [{ code, name, lat, lng }]
490490 // for the subdivisions (states/provinces/regions) we draw when the
491491 // user zooms in. US gets the full 50-state set above; the rest below
492492 // are major-administrative-divisions only. Inactive entries render as
493493 // just an abbreviation label (no dot), active ones (with traffic in
494494 // window.GLOBE_STATE_DATA) get the purple dot + count -- same rules
495495 // the US set follows.
496496 //
497497 // Server-side per-region traffic is currently US-only -- non-US
498498 // subdivisions render as geography labels for now and start
499499 // populating with real counts as the tracking_sessions.region
500500 // column gets backfilled for those countries.
501501 // ====================================================================
502502 var SUBDIVISIONS = {
503503 US: US_STATES,
504504
505505 // Canada -- 13 provinces and territories
506506 CA: [
507507 { code:'AB', name:'Alberta', lat:55.0, lng:-115.0 },
508508 { code:'BC', name:'British Columbia', lat:54.7, lng:-125.6 },
509509 { code:'MB', name:'Manitoba', lat:53.7, lng: -98.8 },
510510 { code:'NB', name:'New Brunswick', lat:46.6, lng: -66.4 },
511511 { code:'NL', name:'Newfoundland & Labrador',lat:53.1, lng: -57.7 },
512512 { code:'NS', name:'Nova Scotia', lat:45.0, lng: -63.0 },
513513 { code:'NT', name:'Northwest Territories', lat:64.8, lng:-124.8 },
514514 { code:'NU', name:'Nunavut', lat:70.0, lng: -90.0 },
515515 { code:'ON', name:'Ontario', lat:51.2, lng: -85.3 },
516516 { code:'PE', name:'Prince Edward Island', lat:46.3, lng: -63.0 },
517517 { code:'QC', name:'Quebec', lat:52.0, lng: -71.0 },
518518 { code:'SK', name:'Saskatchewan', lat:54.0, lng:-106.0 },
519519 { code:'YT', name:'Yukon', lat:64.3, lng:-135.0 }
520520 ],
521521
522522 // Mexico -- 32 federal entities (31 states + Mexico City)
523523 MX: [
524524 { code:'AGU', name:'Aguascalientes', lat:22.0, lng:-102.3 },
525525 { code:'BCN', name:'Baja California', lat:30.8, lng:-115.3 },
526526 { code:'BCS', name:'Baja California Sur',lat:26.0, lng:-111.7 },
527527 { code:'CAM', name:'Campeche', lat:18.8, lng: -90.4 },
528528 { code:'CHP', name:'Chiapas', lat:16.7, lng: -92.6 },
529529 { code:'CHH', name:'Chihuahua', lat:28.6, lng:-106.0 },
530530 { code:'COA', name:'Coahuila', lat:27.3, lng:-102.0 },
531531 { code:'COL', name:'Colima', lat:19.1, lng:-103.7 },
532532 { code:'CMX', name:'Mexico City', lat:19.4, lng: -99.1 },
533533 { code:'DUR', name:'Durango', lat:24.6, lng:-104.7 },
534534 { code:'GUA', name:'Guanajuato', lat:21.0, lng:-101.3 },
535535 { code:'GRO', name:'Guerrero', lat:17.4, lng: -99.5 },
536536 { code:'HID', name:'Hidalgo', lat:20.5, lng: -98.8 },
537537 { code:'JAL', name:'Jalisco', lat:20.7, lng:-103.3 },
538538 { code:'MEX', name:'Mexico State', lat:19.3, lng: -99.6 },
539539 { code:'MIC', name:'Michoacan', lat:19.6, lng:-101.7 },
540540 { code:'MOR', name:'Morelos', lat:18.7, lng: -99.2 },
541541 { code:'NAY', name:'Nayarit', lat:21.7, lng:-104.9 },
542542 { code:'NLE', name:'Nuevo Leon', lat:25.6, lng:-100.0 },
543543 { code:'OAX', name:'Oaxaca', lat:17.1, lng: -96.7 },
544544 { code:'PUE', name:'Puebla', lat:19.0, lng: -97.9 },
545545 { code:'QUE', name:'Queretaro', lat:20.6, lng:-100.4 },
546546 { code:'ROO', name:'Quintana Roo', lat:19.6, lng: -88.0 },
547547 { code:'SLP', name:'San Luis Potosi', lat:22.6, lng:-100.4 },
548548 { code:'SIN', name:'Sinaloa', lat:25.0, lng:-107.5 },
549549 { code:'SON', name:'Sonora', lat:29.3, lng:-110.3 },
550550 { code:'TAB', name:'Tabasco', lat:18.0, lng: -92.9 },
551551 { code:'TAM', name:'Tamaulipas', lat:24.3, lng: -98.6 },
552552 { code:'TLA', name:'Tlaxcala', lat:19.4, lng: -98.2 },
553553 { code:'VER', name:'Veracruz', lat:19.2, lng: -96.5 },
554554 { code:'YUC', name:'Yucatan', lat:20.7, lng: -89.1 },
555555 { code:'ZAC', name:'Zacatecas', lat:23.3, lng:-103.0 }
556556 ],
557557
558558 // United Kingdom -- 4 constituent nations
559559 GB: [
560560 { code:'ENG', name:'England', lat:52.4, lng: -1.5 },
561561 { code:'SCT', name:'Scotland', lat:56.5, lng: -4.2 },
562562 { code:'WLS', name:'Wales', lat:52.4, lng: -3.8 },
563563 { code:'NIR', name:'Northern Ireland', lat:54.6, lng: -6.5 }
564564 ],
565565
566566 // Australia -- 6 states + 2 mainland territories
567567 AU: [
568568 { code:'NSW', name:'New South Wales', lat:-32.2, lng:147.0 },
569569 { code:'VIC', name:'Victoria', lat:-36.5, lng:144.0 },
570570 { code:'QLD', name:'Queensland', lat:-22.0, lng:144.0 },
571571 { code:'WA', name:'Western Australia', lat:-26.0, lng:121.5 },
572572 { code:'SA', name:'South Australia', lat:-30.0, lng:135.0 },
573573 { code:'TAS', name:'Tasmania', lat:-42.0, lng:146.6 },
574574 { code:'NT', name:'Northern Territory', lat:-19.5, lng:133.4 },
575575 { code:'ACT', name:'Australian Capital Territory',lat:-35.5,lng:149.0 }
576576 ],
577577
578578 // Germany -- 16 Bundeslander
579579 DE: [
580580 { code:'BW', name:'Baden-Wurttemberg', lat:48.6, lng:9.0 },
581581 { code:'BY', name:'Bavaria', lat:48.8, lng:11.5 },
582582 { code:'BE', name:'Berlin', lat:52.5, lng:13.4 },
583583 { code:'BB', name:'Brandenburg', lat:52.4, lng:13.6 },
584584 { code:'HB', name:'Bremen', lat:53.1, lng:8.8 },
585585 { code:'HH', name:'Hamburg', lat:53.6, lng:10.0 },
586586 { code:'HE', name:'Hesse', lat:50.7, lng:9.0 },
587587 { code:'MV', name:'Mecklenburg-Vorpommern', lat:53.6, lng:12.7 },
588588 { code:'NI', name:'Lower Saxony', lat:52.6, lng:9.8 },
589589 { code:'NW', name:'North Rhine-Westphalia', lat:51.4, lng:7.7 },
590590 { code:'RP', name:'Rhineland-Palatinate', lat:50.0, lng:7.3 },
591591 { code:'SL', name:'Saarland', lat:49.4, lng:7.0 },
592592 { code:'SN', name:'Saxony', lat:51.1, lng:13.2 },
593593 { code:'ST', name:'Saxony-Anhalt', lat:51.9, lng:11.7 },
594594 { code:'SH', name:'Schleswig-Holstein', lat:54.2, lng:9.7 },
595595 { code:'TH', name:'Thuringia', lat:50.9, lng:11.0 }
596596 ],
597597
598598 // Brazil -- 26 states + Federal District
599599 BR: [
600600 { code:'AC', name:'Acre', lat:-9.0, lng:-70.0 },
601601 { code:'AL', name:'Alagoas', lat:-9.6, lng:-36.8 },
602602 { code:'AP', name:'Amapa', lat:1.4, lng:-51.8 },
603603 { code:'AM', name:'Amazonas', lat:-3.5, lng:-62.0 },
604604 { code:'BA', name:'Bahia', lat:-12.0, lng:-41.7 },
605605 { code:'CE', name:'Ceara', lat:-5.4, lng:-39.3 },
606606 { code:'DF', name:'Federal District', lat:-15.8, lng:-47.9 },
607607 { code:'ES', name:'Espirito Santo', lat:-19.2, lng:-40.3 },
608608 { code:'GO', name:'Goias', lat:-15.8, lng:-49.8 },
609609 { code:'MA', name:'Maranhao', lat:-5.0, lng:-45.3 },
610610 { code:'MT', name:'Mato Grosso', lat:-13.0, lng:-55.0 },
611611 { code:'MS', name:'Mato Grosso do Sul', lat:-20.5, lng:-54.5 },
612612 { code:'MG', name:'Minas Gerais', lat:-18.5, lng:-44.4 },
613613 { code:'PA', name:'Para', lat:-3.5, lng:-52.5 },
614614 { code:'PB', name:'Paraiba', lat:-7.2, lng:-36.7 },
615615 { code:'PR', name:'Parana', lat:-24.9, lng:-51.5 },
616616 { code:'PE', name:'Pernambuco', lat:-8.5, lng:-37.8 },
617617 { code:'PI', name:'Piaui', lat:-7.7, lng:-43.0 },
618618 { code:'RJ', name:'Rio de Janeiro', lat:-22.3, lng:-42.6 },
619619 { code:'RN', name:'Rio Grande do Norte', lat:-5.8, lng:-36.6 },
620620 { code:'RS', name:'Rio Grande do Sul', lat:-30.0, lng:-53.5 },
621621 { code:'RO', name:'Rondonia', lat:-11.5, lng:-63.0 },
622622 { code:'RR', name:'Roraima', lat:2.0, lng:-61.5 },
623623 { code:'SC', name:'Santa Catarina', lat:-27.3, lng:-50.0 },
624624 { code:'SP', name:'Sao Paulo', lat:-22.2, lng:-48.6 },
625625 { code:'SE', name:'Sergipe', lat:-10.6, lng:-37.4 },
626626 { code:'TO', name:'Tocantins', lat:-10.2, lng:-48.3 }
627627 ],
628628
629629 // India -- 15 most-populous states + Delhi (full set is 28+8, the
630630 // rest can be added incrementally without touching anything else)
631631 IN: [
632632 { code:'MH', name:'Maharashtra', lat:19.7, lng:75.7 },
633633 { code:'UP', name:'Uttar Pradesh', lat:26.8, lng:80.9 },
634634 { code:'TN', name:'Tamil Nadu', lat:11.1, lng:78.7 },
635635 { code:'KA', name:'Karnataka', lat:15.3, lng:75.7 },
636636 { code:'WB', name:'West Bengal', lat:22.9, lng:87.9 },
637637 { code:'GJ', name:'Gujarat', lat:22.3, lng:71.2 },
638638 { code:'RJ', name:'Rajasthan', lat:27.0, lng:74.2 },
639639 { code:'AP', name:'Andhra Pradesh', lat:15.9, lng:80.0 },
640640 { code:'TS', name:'Telangana', lat:18.1, lng:79.0 },
641641 { code:'KL', name:'Kerala', lat:10.9, lng:76.3 },
642642 { code:'MP', name:'Madhya Pradesh', lat:23.5, lng:78.0 },
643643 { code:'DL', name:'Delhi', lat:28.6, lng:77.2 },
644644 { code:'PB', name:'Punjab', lat:31.1, lng:75.3 },
645645 { code:'HR', name:'Haryana', lat:29.1, lng:76.1 },
646646 { code:'OD', name:'Odisha', lat:20.9, lng:85.1 },
647647 { code:'BR', name:'Bihar', lat:25.1, lng:85.3 },
648648 { code:'AS', name:'Assam', lat:26.2, lng:92.9 },
649649 { code:'JK', name:'Jammu & Kashmir', lat:33.8, lng:75.0 }
650650 ],
651651
652652 // Japan -- 8 traditional regions (47 prefectures is overkill at
653653 // this zoom; regions read cleaner and still cover everything)
654654 JP: [
655655 { code:'HOK', name:'Hokkaido', lat:43.5, lng:142.5 },
656656 { code:'TOH', name:'Tohoku', lat:39.0, lng:141.0 },
657657 { code:'KAN', name:'Kanto', lat:36.0, lng:139.5 },
658658 { code:'CHU', name:'Chubu', lat:36.5, lng:137.5 },
659659 { code:'KIN', name:'Kansai', lat:34.7, lng:135.5 },
660660 { code:'CHG', name:'Chugoku', lat:34.7, lng:133.0 },
661661 { code:'SHI', name:'Shikoku', lat:33.7, lng:133.5 },
662662 { code:'KYU', name:'Kyushu', lat:32.5, lng:130.7 }
663663 ]
664664 };
665665
666666 function initGlobes() {
667667 var hosts = document.querySelectorAll('[data-globe]');
668668 for (var i = 0; i < hosts.length; i++) initOne(hosts[i]);
669669 }
670670
671671 function initOne(host) {
672672 var W = parseInt(host.getAttribute('data-width') || '800', 10);
673673 var H = parseInt(host.getAttribute('data-height') || '520', 10);
674674 var DRILL_HREF = host.getAttribute('data-drill-href') || '/admin_visitors.cgi';
675675 var TUTORIAL = host.getAttribute('data-tutorial') === '1';
676676 // Auto-rotation is opt-in only -- the per-frame DOM rewrites it
677677 // requires trigger Chrome's scroll-anchoring on long pages, which
678678 // makes the whole page lurch up/down. Set data-autospin="1" only
679679 // on hosts that can guarantee they won't scroll (e.g. focus mode).
680680 var AUTOSPIN = host.getAttribute('data-autospin') === '1';
681681
682682 // Defensively disable scroll anchoring on the host so even if a
683683 // frame paint changes its measured height by a fraction of a pixel,
684684 // the browser doesn't try to compensate by scrolling the page.
685685 host.style.overflowAnchor = 'none';
686686 host.style.overflow = 'hidden';
687687 host.style.contain = 'layout paint';
688688
689689 var data = window.GLOBE_DATA || { countries: [] };
690690 var countries = data.countries || [];
691691 var maxCount = 1;
692692 for (var k = 0; k < countries.length; k++) {
693693 if ((countries[k].count || 0) > maxCount) maxCount = countries[k].count;
694694 }
695695
696696 // Viewing state -- the rAF tick reads these. Persisted to
697697 // sessionStorage on every change so the 8-second auto-refresh on
698698 // admin_visitors.cgi doesn't reset the user's rotation/zoom.
699699 var STATE_KEY = 'taskforge.globe.' + (host.getAttribute('data-state-key') || DRILL_HREF);
700700 var lat0 = 20;
701701 var lng0 = -30;
702702 var R = Math.min(W, H) * 0.42;
703703 try {
704704 var saved = sessionStorage.getItem(STATE_KEY);
705705 if (saved) {
706706 var s = JSON.parse(saved);
707707 if (s && typeof s.lat0 === 'number') lat0 = s.lat0;
708708 if (s && typeof s.lng0 === 'number') lng0 = s.lng0;
709709 if (s && typeof s.R === 'number') R = s.R;
710710 }
711711 } catch (e) { /* sessionStorage may be disabled -- non-fatal */ }
712712 var Rtarget = R; // wheel zoom eases toward this
713713 var dirty = true; // re-render flag
714714
715715 function saveState() {
716716 try {
717717 sessionStorage.setItem(STATE_KEY, JSON.stringify({ lat0: lat0, lng0: lng0, R: R }));
718718 } catch (e) { /* ignore */ }
719719 }
720720
721721 var SVG_NS = 'http://www.w3.org/2000/svg';
722722 var svg = document.createElementNS(SVG_NS, 'svg');
723723 svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
724724 svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
725725 svg.style.display = 'block';
726726 svg.style.width = '100%';
727727 svg.style.height = 'auto';
728728 svg.style.userSelect = 'none';
729729 svg.style.touchAction = 'none';
730730 svg.style.cursor = 'grab';
731731 host.appendChild(svg);
732732
733733 // Floating HTML tooltip layered over the SVG.
734734 host.style.position = host.style.position || 'relative';
735735 var tip = document.createElement('div');
736736 tip.className = 'globe-tip';
737737 tip.style.cssText =
738738 'position:absolute;pointer-events:none;background:rgba(15,23,42,0.95);' +
739739 'border:1px solid rgba(160,26,44,0.55);border-radius:8px;' +
740740 'padding:8px 12px;color:#fff;font:600 12px/1.3 system-ui,sans-serif;' +
741741 'white-space:nowrap;box-shadow:0 6px 18px rgba(0,0,0,0.5);' +
742742 'transform:translate(-50%,-115%);opacity:0;transition:opacity 0.12s ease;' +
743743 'z-index:5;left:0;top:0';
744744 tip.innerHTML = '';
745745 host.appendChild(tip);
746746
747747 // ---- Click-to-drill modal ------------------------------------------
748748 // Built once per host. Click a country/state dot -> AJAX-fetch the
749749 // detail body and slide it into the modal. No page reload, the
750750 // globe keeps its rotation/zoom state.
751751 var modal = document.querySelector('[data-globe-modal]');
752752 if (!modal) {
753753 modal = document.createElement('div');
754754 modal.setAttribute('data-globe-modal', '');
755755 modal.style.cssText =
756756 'position:fixed;inset:0;z-index:1000;display:none;' +
757757 'background:rgba(3,8,31,0.78);backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);' +
758758 'align-items:flex-start;justify-content:center;padding:48px 16px;overflow-y:auto';
759759 modal.innerHTML =
760760 '<div data-globe-modal-card style="max-width:920px;width:100%;background:var(--col-surface-1,#0c1729);' +
761761 'border:1px solid var(--col-border,rgba(160,26,44,0.35));border-radius:14px;padding:24px;' +
762762 'box-shadow:0 30px 60px rgba(0,0,0,0.6);position:relative">' +
763763 '<button data-globe-modal-close aria-label="Close" style="' +
764764 'position:absolute;top:14px;right:14px;background:rgba(160,26,44,0.15);' +
765765 'border:1px solid rgba(160,26,44,0.5);color:#fff;border-radius:8px;' +
766766 'width:32px;height:32px;cursor:pointer;font-size:18px;line-height:1;display:grid;' +
767767 'place-items:center">&times;</button>' +
768768 '<div data-globe-modal-body><div style="padding:48px;text-align:center;color:var(--col-text-3,#94a3b8)">Loading&hellip;</div></div>' +
769769 '</div>';
770770 document.body.appendChild(modal);
771771 var closeBtn = modal.querySelector('[data-globe-modal-close]');
772772 var modalCard = modal.querySelector('[data-globe-modal-card]');
773773 function closeModal() { modal.style.display = 'none'; }
774774 closeBtn.addEventListener('click', closeModal);
775775 modal.addEventListener('click', function (e) {
776776 if (e.target === modal) closeModal();
777777 });
778778 document.addEventListener('keydown', function (e) {
779779 if (e.key === 'Escape' && modal.style.display !== 'none') closeModal();
780780 });
781781 }
782782 var modalBody = modal.querySelector('[data-globe-modal-body]');
783783
784784 // Last-loaded country/region so the slider can re-fetch the same
785785 // scope with a new date window.
786786 var currentDrillCode = '';
787787 var currentDrillRegion = '';
788788
789789 function openDrillModal(code, region, daysFrom, daysTo) {
790790 modal.style.display = 'flex';
791791 modalBody.innerHTML = '<div style="padding:48px;text-align:center;color:var(--col-text-3,#94a3b8)">Loading&hellip;</div>';
792792 currentDrillCode = code || '';
793793 currentDrillRegion = region || '';
794794 // Detail fetch hits the same CGI the host page declared in
795795 // data-drill-href -- admin_visitors.cgi for the admin globe,
796796 // traffic_reports.cgi for the creator globe.
797797 var url = DRILL_HREF + '?country_detail=' + encodeURIComponent(code);
798798 if (region) url += '&region=' + encodeURIComponent(region);
799799 if (typeof daysFrom !== 'undefined' && daysFrom !== null) url += '&days_from=' + daysFrom;
800800 if (typeof daysTo !== 'undefined' && daysTo !== null) url += '&days_to=' + daysTo;
801801 url += '&_=' + Date.now();
802802 fetch(url, { credentials: 'same-origin' })
803803 .then(function (r) { return r.ok ? r.text() : Promise.reject(r.status); })
804804 .then(function (html) { modalBody.innerHTML = html; wireDetail(); })
805805 .catch(function () {
806806 modalBody.innerHTML =
807807 '<div style="padding:32px;color:#ef4444">Couldn\'t load detail. Try again in a moment.</div>';
808808 });
809809 }
810810
811811 // Hook up the in-modal chart hover after each detail load. Since
812812 // `innerHTML = html` doesn't execute embedded <script> tags, we
813813 // wire it from this side.
814814 function wireDetail() {
815815 wireChartHover();
816816 }
817817
818818 function wireChartHover() {
819819 var card = modalBody.querySelector('[data-chart-card]');
820820 if (!card) return;
821821 var svg = card.querySelector('[data-chart-svg]');
822822 var overlay = card.querySelector('[data-chart-overlay]');
823823 var line = card.querySelector('[data-chart-marker-line]');
824824 var dot = card.querySelector('[data-chart-marker-dot]');
825825 var tip = card.querySelector('[data-chart-tip]');
826826 var dataEl = card.querySelector('[data-chart-data]');
827827 if (!svg || !overlay || !line || !dot || !tip || !dataEl) return;
828828 var pts;
829829 try { pts = JSON.parse(dataEl.textContent || '[]'); } catch (e) { pts = []; }
830830 if (!pts.length) return;
831831
832832 var monthShort = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
833833 function fmtDate(ymd) {
834834 if (!ymd) return '';
835835 var m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
836836 if (!m) return ymd;
837837 return monthShort[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10) + ', ' + m[1];
838838 }
839839
840840 function findNearest(svgX) {
841841 // pts are sorted by x already; pick the closest.
842842 var lo = 0, hi = pts.length - 1, best = 0, bestD = Infinity;
843843 for (var i = 0; i < pts.length; i++) {
844844 var d = Math.abs(pts[i].x - svgX);
845845 if (d < bestD) { bestD = d; best = i; }
846846 }
847847 return pts[best];
848848 }
849849
850850 overlay.addEventListener('mousemove', function (e) {
851851 // Convert client X to SVG user-space X via the rendered rect.
852852 var rect = svg.getBoundingClientRect();
853853 var vbW = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.width : rect.width;
854854 var vbH = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.height : rect.height;
855855 var sx = ((e.clientX - rect.left) / rect.width) * vbW;
856856 var sy_ignore = ((e.clientY - rect.top) / rect.height) * vbH;
857857 var p = findNearest(sx);
858858 if (!p) return;
859859 line.setAttribute('x1', p.x); line.setAttribute('x2', p.x);
860860 line.style.display = '';
861861 dot.setAttribute('cx', p.x); dot.setAttribute('cy', p.y);
862862 dot.style.display = '';
863863 // Tooltip text + position (HTML overlay, positioned in pixels
864864 // relative to the chart wrapper).
865865 tip.innerHTML = fmtDate(p.date) + ' &middot; <span style="color:#d97a86">' + p.visitors + '</span> visits';
866866 var wrapRect = tip.parentNode.getBoundingClientRect();
867867 tip.style.left = (e.clientX - wrapRect.left) + 'px';
868868 tip.style.top = (e.clientY - wrapRect.top) + 'px';
869869 tip.style.display = 'block';
870870 });
871871 overlay.addEventListener('mouseleave', function () {
872872 line.style.display = 'none';
873873 dot.style.display = 'none';
874874 tip.style.display = 'none';
875875 });
876876 }
877877
878878 // Intercept clicks on the modal body's [data-region-link] rows so
879879 // each state in the "Traffic by state" panel reloads THIS modal
880880 // scoped to that state, instead of navigating to a separate page.
881881 modal.addEventListener('click', function (e) {
882882 var a = e.target && e.target.closest ? e.target.closest('[data-region-link]') : null;
883883 if (!a) return;
884884 e.preventDefault();
885885 var href = a.getAttribute('href') || '';
886886 var cm = href.match(/[?&]country_detail=([^&]+)/);
887887 var rm = href.match(/[?&]region=([^&]+)/);
888888 var cc = cm ? decodeURIComponent(cm[1]) : '';
889889 var rg = rm ? decodeURIComponent(rm[1]) : '';
890890 if (cc) openDrillModal(cc, rg);
891891 });
892892
893893 // ---- Static decoration: defs + stars + sphere ----------------------
894894 var defs = document.createElementNS(SVG_NS, 'defs');
895895 defs.innerHTML =
896896 '<radialGradient id="g-sphere" cx="35%" cy="32%" r="75%">' +
897897 '<stop offset="0%" stop-color="#4d0e15"/>' +
898898 '<stop offset="55%" stop-color="#3a0a11"/>' +
899899 '<stop offset="100%" stop-color="#0c1018"/>' +
900900 '</radialGradient>' +
901901 '<radialGradient id="g-glow" cx="50%" cy="50%" r="50%">' +
902902 '<stop offset="70%" stop-color="rgba(160,26,44,0)"/>' +
903903 '<stop offset="90%" stop-color="rgba(160,26,44,0.25)"/>' +
904904 '<stop offset="100%" stop-color="rgba(160,26,44,0)"/>' +
905905 '</radialGradient>';
906906 svg.appendChild(defs);
907907
908908 var stars = document.createElementNS(SVG_NS, 'g');
909909 var seed = 1;
910910 function rng() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
911911 for (var s = 0; s < 120; s++) {
912912 var dot = document.createElementNS(SVG_NS, 'circle');
913913 dot.setAttribute('cx', (rng() * W).toFixed(1));
914914 dot.setAttribute('cy', (rng() * H).toFixed(1));
915915 dot.setAttribute('r', (rng() * 1.2 + 0.3).toFixed(1));
916916 dot.setAttribute('fill', '#fff');
917917 dot.setAttribute('opacity', (rng() * 0.7 + 0.2).toFixed(2));
918918 stars.appendChild(dot);
919919 }
920920 svg.appendChild(stars);
921921
922922 var glow = document.createElementNS(SVG_NS, 'circle');
923923 glow.setAttribute('cx', W/2); glow.setAttribute('cy', H/2);
924924 glow.setAttribute('fill', 'url(#g-glow)');
925925 svg.appendChild(glow);
926926
927927 var sphere = document.createElementNS(SVG_NS, 'circle');
928928 sphere.setAttribute('cx', W/2); sphere.setAttribute('cy', H/2);
929929 sphere.setAttribute('fill', 'url(#g-sphere)');
930930 sphere.setAttribute('stroke', 'rgba(160,26,44,0.35)');
931931 sphere.setAttribute('stroke-width', '1');
932932 svg.appendChild(sphere);
933933
934934 var gGrid = document.createElementNS(SVG_NS, 'g');
935935 var gContinents = document.createElementNS(SVG_NS, 'g');
936936 var gDots = document.createElementNS(SVG_NS, 'g');
937937 svg.appendChild(gGrid);
938938 svg.appendChild(gContinents);
939939 svg.appendChild(gDots);
940940
941941 // Hint label gets a translucent background + soft purple border
942942 // so it reads as a discrete UI chip on top of the sphere instead
943943 // of just floating text. Rect is appended first so the text
944944 // paints on top of it.
945945 var hintBg = document.createElementNS(SVG_NS, 'rect');
946946 hintBg.setAttribute('fill', 'rgba(15,23,42,0.72)');
947947 hintBg.setAttribute('stroke', 'rgba(160,26,44,0.55)');
948948 hintBg.setAttribute('stroke-width', '1');
949949 hintBg.setAttribute('rx', '6');
950950 hintBg.setAttribute('ry', '6');
951951 svg.appendChild(hintBg);
952952
953953 var hint = document.createElementNS(SVG_NS, 'text');
954954 hint.setAttribute('x', 18);
955955 hint.setAttribute('y', 24);
956956 hint.setAttribute('fill', '#d97a86');
957957 hint.setAttribute('font-size', '11');
958958 hint.setAttribute('font-family', 'ui-monospace, SFMono-Regular, monospace');
959959 hint.setAttribute('letter-spacing', '1');
960960 hint.textContent = AUTOSPIN
961961 ? 'DRAG TO ROTATE - SCROLL TO ZOOM - CLICK A DOT TO DRILL'
962962 : 'DRAG TO ROTATE - SCROLL OVER GLOBE TO ZOOM - CLICK A DOT TO DRILL';
963963 svg.appendChild(hint);
964964
965965 // Size the rect to fit the text once the text is in the DOM.
966966 // getBBox() works on SVG without a layout pass, but we wrap in
967967 // a try/catch in case the host isn't visible yet.
968968 requestAnimationFrame(function () {
969969 try {
970970 var bb = hint.getBBox();
971971 var padX = 10, padY = 5;
972972 hintBg.setAttribute('x', (bb.x - padX).toFixed(1));
973973 hintBg.setAttribute('y', (bb.y - padY).toFixed(1));
974974 hintBg.setAttribute('width', (bb.width + padX * 2).toFixed(1));
975975 hintBg.setAttribute('height', (bb.height + padY * 2).toFixed(1));
976976 } catch (e) {
977977 // Fallback approx dimensions if the bbox call fails.
978978 hintBg.setAttribute('x', 8);
979979 hintBg.setAttribute('y', 10);
980980 hintBg.setAttribute('width', 520);
981981 hintBg.setAttribute('height', 22);
982982 }
983983 });
984984
985985 // ---- Projection / clipping helpers --------------------------------
986986 function project(lat, lng) {
987987 var phi = lat * D2R;
988988 var lam = (lng - lng0) * D2R;
989989 var phi0 = lat0 * D2R;
990990 var sinPhi = Math.sin(phi), cosPhi = Math.cos(phi);
991991 var sinPhi0 = Math.sin(phi0), cosPhi0 = Math.cos(phi0);
992992 var cosLam = Math.cos(lam);
993993 var cosc = sinPhi0 * sinPhi + cosPhi0 * cosPhi * cosLam;
994994 return {
995995 x: (W/2) + R * cosPhi * Math.sin(lam),
996996 y: (H/2) - R * (cosPhi0 * sinPhi - sinPhi0 * cosPhi * cosLam),
997997 cosc: cosc,
998998 visible: cosc >= 0
999999 };
10001000 }
10011001
10021002 // Linearly interpolate between two lat/lng points and find the
10031003 // crossing where cosc transitions from visible->hidden (or vice
10041004 // versa). Binary search keeps it fast and accurate enough that
10051005 // continents and grid lines hug the sphere edge cleanly.
10061006 function findHorizon(p1, p2) {
10071007 var lo = 0, hi = 1;
10081008 for (var i = 0; i < 10; i++) {
10091009 var mid = (lo + hi) * 0.5;
10101010 var lat = p1[0] + (p2[0] - p1[0]) * mid;
10111011 var lng = p1[1] + (p2[1] - p1[1]) * mid;
10121012 var v = project(lat, lng).visible;
10131013 if (v === project(p1[0], p1[1]).visible) lo = mid; else hi = mid;
10141014 }
10151015 var t = (lo + hi) * 0.5;
10161016 return [ p1[0] + (p2[0] - p1[0]) * t, p1[1] + (p2[1] - p1[1]) * t ];
10171017 }
10181018
10191019 // Convert a [lat,lng] polyline into SVG path data, clipping segments
10201020 // that cross the horizon. Returns a path string ready for d="...".
10211021 function polyToPath(points) {
10221022 if (points.length < 2) return '';
10231023 var d = '';
10241024 var prev = points[0];
10251025 var prevP = project(prev[0], prev[1]);
10261026 if (prevP.visible) d += 'M' + prevP.x.toFixed(1) + ',' + prevP.y.toFixed(1);
10271027 for (var i = 1; i < points.length; i++) {
10281028 var cur = points[i];
10291029 var curP = project(cur[0], cur[1]);
10301030 if (prevP.visible && curP.visible) {
10311031 d += 'L' + curP.x.toFixed(1) + ',' + curP.y.toFixed(1);
10321032 } else if (prevP.visible && !curP.visible) {
10331033 var bx = findHorizon(prev, cur);
10341034 var bP = project(bx[0], bx[1]);
10351035 d += 'L' + bP.x.toFixed(1) + ',' + bP.y.toFixed(1);
10361036 } else if (!prevP.visible && curP.visible) {
10371037 var bx2 = findHorizon(cur, prev);
10381038 var b2P = project(bx2[0], bx2[1]);
10391039 d += 'M' + b2P.x.toFixed(1) + ',' + b2P.y.toFixed(1);
10401040 d += 'L' + curP.x.toFixed(1) + ',' + curP.y.toFixed(1);
10411041 }
10421042 prev = cur; prevP = curP;
10431043 }
10441044 return d;
10451045 }
10461046
10471047 // ---- Render --------------------------------------------------------
10481048 function render() {
10491049 sphere.setAttribute('r', R);
10501050 glow.setAttribute('r', R * 1.18);
10511051
10521052 var gridParts = [];
10531053 // Sample meridians/parallels at fine resolution so they curve
10541054 // smoothly; horizon clipping handles where they exit the disc.
10551055 for (var mLng = -180; mLng < 180; mLng += 30) {
10561056 var meridian = [];
10571057 for (var lat = -90; lat <= 90; lat += 3) meridian.push([lat, mLng]);
10581058 var p = polyToPath(meridian);
10591059 if (p) gridParts.push(p);
10601060 }
10611061 for (var pLat = -60; pLat <= 60; pLat += 30) {
10621062 var parallel = [];
10631063 for (var lng = -180; lng <= 180; lng += 3) parallel.push([pLat, lng]);
10641064 var p2 = polyToPath(parallel);
10651065 if (p2) gridParts.push(p2);
10661066 }
10671067 gGrid.innerHTML = '<path d="' + gridParts.join(' ') + '" fill="none" stroke="rgba(96,165,250,0.18)" stroke-width="0.8"/>';
10681068
10691069 var contParts = [];
10701070 for (var c = 0; c < CONTINENTS_DENSE.length; c++) {
10711071 var poly = CONTINENTS_DENSE[c];
10721072 // Close the ring so the clipped path also closes back to its start.
10731073 var closed = poly.concat([poly[0]]);
10741074 var p3 = polyToPath(closed);
10751075 if (p3) contParts.push(p3);
10761076 }
10771077
10781078 // Internal political borders (polylines, not closed rings).
10791079 var borderParts = [];
10801080 for (var bj = 0; bj < COUNTRY_BORDERS_DENSE.length; bj++) {
10811081 var bp = polyToPath(COUNTRY_BORDERS_DENSE[bj]);
10821082 if (bp) borderParts.push(bp);
10831083 }
10841084
10851085 // Stroke-only continents. Filling produced artifact triangles
10861086 // when a polygon spans the horizon -- the auto-close in SVG path
10871087 // fill connected exit/entry points across continent interiors.
10881088 // With stroke-only we get clean coastline outlines on the sphere,
10891089 // and the radial sphere gradient still reads as "land vs ocean".
10901090 // Borders render in a softer color/weight beneath so coastlines
10911091 // stay the dominant outline.
10921092 gContinents.innerHTML =
10931093 '<path d="' + borderParts.join(' ') + '" fill="none" ' +
10941094 'stroke="rgba(167,139,250,0.45)" stroke-width="0.8" ' +
10951095 'stroke-dasharray="3 2" stroke-linejoin="round" stroke-linecap="round"/>' +
10961096 '<path d="' + contParts.join(' ') + '" fill="none" ' +
10971097 'stroke="rgba(167,139,250,0.85)" stroke-width="1.2" ' +
10981098 'stroke-linejoin="round" stroke-linecap="round"/>';
10991099
11001100 // Subdivision labels (US states, Canadian provinces, Mexican
11011101 // states, German lander, Australian states, UK nations, Brazilian
11021102 // states, Indian states, Japanese regions) appear once the view
11031103 // is zoomed in enough that the parent country's single dot would
11041104 // crowd the geography. Threshold: sphere radius > 0.85 x canvas
11051105 // baseline -- "country fills most of the disc."
11061106 //
11071107 // Server-side per-region traffic is currently US-only via
11081108 // window.GLOBE_STATE_DATA.US -- non-US subdivisions render as
11091109 // geography labels (no count). When tracking_sessions.region
11101110 // gets backfilled for other countries the data just slots in.
11111111 // Track which parent countries had subdivisions rendered so the
11121112 // country-dot loop below knows to skip those duplicate markers.
11131113 var stateZoom = R > Math.min(W, H) * 0.85;
11141114 var dotsHtml = '';
11151115 var subDivCountriesRendered = {};
11161116
11171117 if (stateZoom) {
11181118 for (var subCode in SUBDIVISIONS) {
11191119 if (!SUBDIVISIONS.hasOwnProperty(subCode)) continue;
11201120 var subList = SUBDIVISIONS[subCode];
11211121 // Per-country traffic counts (server-side data; non-US still
11221122 // gets the labels for free even without counts).
11231123 var subCounts = (window.GLOBE_STATE_DATA && window.GLOBE_STATE_DATA[subCode]) || {};
11241124 // Busiest subdivision drives relative dot sizing.
11251125 var subMax = 0;
11261126 for (var skey in subCounts) {
11271127 if (subCounts.hasOwnProperty(skey) && subCounts[skey] > subMax) subMax = subCounts[skey];
11281128 }
11291129 if (subMax < 1) subMax = 1;
11301130
11311131 // Cheap visibility pre-check: project the parent country
11321132 // centroid; if its centroid is on the far side of the
11331133 // sphere, skip iterating the whole subdivision list for it
11341134 // (saves a few hundred projects per frame at high zoom).
11351135 var parent = null;
11361136 for (var pci = 0; pci < COUNTRY_CENTROIDS.length; pci++) {
11371137 if (COUNTRY_CENTROIDS[pci].code === subCode) { parent = COUNTRY_CENTROIDS[pci]; break; }
11381138 }
11391139 if (parent) {
11401140 var parentProj = project(parent.lat, parent.lng);
11411141 if (!parentProj.visible) continue;
11421142 }
11431143 subDivCountriesRendered[subCode] = 1;
11441144
11451145 for (var si = 0; si < subList.length; si++) {
11461146 var st = subList[si];
11471147 var sp = project(st.lat, st.lng);
11481148 if (!sp.visible) continue;
11491149 var alphaS = Math.max(0.45, Math.min(1, sp.cosc * 1.6));
11501150 var stCount = subCounts[st.name] || 0;
11511151 // Active subdivisions (with traffic) get a bigger purple
11521152 // dot + count appended to the abbreviation. Inactive ones
11531153 // render as just the abbreviation -- geography label, no
11541154 // dot, so the canvas stays uncluttered when zero-data.
11551155 var active = stCount > 0;
11561156 var srRatio = active ? Math.sqrt(stCount / subMax) : 0;
11571157 var sr = active ? (3.5 + srRatio * 8) : 0;
11581158 var labelText = active ? (st.code + ' &middot; ' + stCount) : st.code;
11591159 var labelColor = active ? '#fff' : 'rgba(203,213,225,0.92)';
11601160 var labelX = sp.x + (active ? (sr + 3) : 0);
11611161 dotsHtml +=
11621162 '<g class="globe-country" data-code="' + subCode + '" data-region="' + st.name + '" ' +
11631163 'data-region-code="' + st.code + '" ' +
11641164 'data-name="' + st.name + '" data-count="' + stCount + '">';
11651165 if (active) {
11661166 dotsHtml +=
11671167 '<circle cx="' + sp.x.toFixed(1) + '" cy="' + sp.y.toFixed(1) + '" r="' + sr.toFixed(1) + '" ' +
11681168 'fill="rgba(160,26,44,' + (alphaS * 0.7).toFixed(2) + ')" ' +
11691169 'stroke="rgba(167,139,250,' + alphaS.toFixed(2) + ')" stroke-width="1" ' +
11701170 'style="cursor:pointer"/>';
11711171 }
11721172 dotsHtml +=
11731173 '<text x="' + labelX.toFixed(1) + '" y="' + (sp.y + 4).toFixed(1) + '" ' +
11741174 'fill="' + labelColor + '" font-size="11" font-weight="700" ' +
11751175 'font-family="ui-monospace, SFMono-Regular, monospace" ' +
11761176 (active ? '' : 'text-anchor="middle" ') +
11771177 'pointer-events="none" ' +
11781178 'style="paint-order:stroke;stroke:rgba(3,8,31,0.9);stroke-width:3px">' +
11791179 labelText + '</text>' +
11801180 '</g>';
11811181 }
11821182 }
11831183 }
11841184
11851185 // Country dots + labels. Strategy:
11861186 // - Every entry in COUNTRY_CENTROIDS gets a *label* so the user
11871187 // can identify the geography even on a fresh install with no
11881188 // traffic yet.
11891189 // - Only countries that appear in the traffic feed get a *dot*
11901190 // and a count appended to the label.
11911191 // - Top-N traffic countries render first so they win placement
11921192 // priority in overlapping clusters.
11931193 // - Other countries (no traffic) render last in a dimmer style.
11941194 //
11951195 // window.GLOBE_DATA.countries -> map by code for O(1) lookup
11961196 var traffMap = {};
11971197 for (var ci = 0; ci < countries.length; ci++) {
11981198 traffMap[countries[ci].code] = countries[ci];
11991199 }
12001200 var ranked = countries.slice().sort(function (a, b) { return (b.count||0) - (a.count||0); });
12011201
12021202 // Build the render order: traffic countries first (sorted by
12031203 // count desc), then every other country from the centroid table.
12041204 var seenCode = {};
12051205 var renderOrder = [];
12061206 for (var ri = 0; ri < ranked.length; ri++) {
12071207 renderOrder.push({ code: ranked[ri].code, name: ranked[ri].name,
12081208 lat: ranked[ri].lat, lng: ranked[ri].lng,
12091209 count: ranked[ri].count, hasTraffic: true });
12101210 seenCode[ranked[ri].code] = 1;
12111211 }
12121212 for (var oi = 0; oi < COUNTRY_CENTROIDS.length; oi++) {
12131213 var oc = COUNTRY_CENTROIDS[oi];
12141214 if (seenCode[oc.code]) continue;
12151215 renderOrder.push({ code: oc.code, name: oc.name,
12161216 lat: oc.lat, lng: oc.lng,
12171217 count: 0, hasTraffic: false });
12181218 }
12191219
12201220 var placed = []; // [{x1,y1,x2,y2}, ...]
12211221 function overlaps(box) {
12221222 for (var i = 0; i < placed.length; i++) {
12231223 var p = placed[i];
12241224 if (!(box.x2 < p.x1 || box.x1 > p.x2 || box.y2 < p.y1 || box.y1 > p.y2)) return true;
12251225 }
12261226 return false;
12271227 }
12281228
12291229 for (var d = 0; d < renderOrder.length; d++) {
12301230 var cdata = renderOrder[d];
12311231 var dp = project(cdata.lat, cdata.lng);
12321232 if (!dp.visible) continue;
12331233 var alpha = Math.max(0.4, Math.min(1, dp.cosc * 1.4));
12341234 var name = (cdata.name || '').replace(/"/g, '&quot;').replace(/</g,'&lt;');
12351235
12361236 // When zoomed in enough that this country's state/region dots
12371237 // are rendering, hide the country-level dot AND label entirely.
12381238 // The state dots already show where the traffic sits; the
12391239 // country dot at that point just covers one state (e.g. the
12401240 // US centroid covers KS) and confuses the read. For overall
12411241 // country totals the user can zoom out and click the dot.
12421242 if (stateZoom && subDivCountriesRendered[cdata.code]) continue;
12431243
12441244 var hasTraffic = cdata.hasTraffic && (cdata.count || 0) > 0;
12451245 var ratio = hasTraffic ? Math.sqrt((cdata.count || 0) / maxCount) : 0;
12461246 var rdot = hasTraffic ? (4 + ratio * 14) : 0;
12471247
12481248 dotsHtml +=
12491249 '<g class="globe-country" data-code="' + cdata.code + '" ' +
12501250 'data-name="' + name + '" data-count="' + (cdata.count || 0) + '" ' +
12511251 'data-x="' + dp.x.toFixed(1) + '" data-y="' + dp.y.toFixed(1) + '">';
12521252
12531253 if (hasTraffic) {
12541254 dotsHtml +=
12551255 '<circle cx="' + dp.x.toFixed(1) + '" cy="' + dp.y.toFixed(1) + '" ' +
12561256 'r="' + rdot.toFixed(1) + '" ' +
12571257 'fill="rgba(160,26,44,' + (alpha * 0.65).toFixed(2) + ')" ' +
12581258 'stroke="rgba(167,139,250,' + alpha.toFixed(2) + ')" stroke-width="1.5" ' +
12591259 'style="cursor:pointer"/>';
12601260 }
12611261
12621262 // Label: "Name · count" for traffic countries, plain "Name"
12631263 // for the rest (dimmer + smaller). Skip if it'd overlap an
12641264 // already-placed label.
12651265 var labelTxt = hasTraffic ? (cdata.name + ' · ' + cdata.count) : cdata.name;
12661266 var labelX = dp.x + (hasTraffic ? rdot + 5 : 7);
12671267 var labelY = dp.y + 4;
12681268 var approxW = labelTxt.length * 6.2 + 4;
12691269 var labelBox = { x1: labelX - 2, y1: labelY - 11, x2: labelX + approxW, y2: labelY + 3 };
12701270 if (!overlaps(labelBox)) {
12711271 placed.push(labelBox);
12721272 var labelColor = hasTraffic ? '#fff' : 'rgba(203,213,225,0.92)';
12731273 var fontSize = hasTraffic ? '11' : '10';
12741274 dotsHtml +=
12751275 '<text x="' + labelX.toFixed(1) + '" y="' + labelY.toFixed(1) + '" ' +
12761276 'fill="' + labelColor + '" font-size="' + fontSize + '" font-weight="700" ' +
12771277 'font-family="ui-monospace, SFMono-Regular, monospace" ' +
12781278 'pointer-events="none" ' +
12791279 'style="paint-order:stroke;stroke:rgba(3,8,31,0.92);stroke-width:3.5px">' +
12801280 labelTxt + '</text>';
12811281 }
12821282 dotsHtml += '</g>';
12831283 }
12841284 gDots.innerHTML = dotsHtml;
12851285 }
12861286
12871287 // ---- Interaction (sets state + dirty; rAF renders) ------------------
12881288 var dragging = false;
12891289 var dragStart = null;
12901290 var dragMoved = false; // true once the pointer moved past a threshold
12911291 var lastInteraction = performance.now();
12921292
12931293 function getPoint(e) {
12941294 if (e.touches && e.touches.length) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
12951295 return { x: e.clientX, y: e.clientY };
12961296 }
12971297
12981298 // Two-finger pinch state. Active only while exactly 2 touches are down.
12991299 var pinching = false;
13001300 var pinchStartDist = 0;
13011301 var pinchStartR = 0;
13021302 function touchDistance(e) {
13031303 if (!e.touches || e.touches.length < 2) return 0;
13041304 var dx = e.touches[0].clientX - e.touches[1].clientX;
13051305 var dy = e.touches[0].clientY - e.touches[1].clientY;
13061306 return Math.sqrt(dx*dx + dy*dy);
13071307 }
13081308
13091309 function onDown(e) {
13101310 // Two-finger touch -> pinch-to-zoom. Take precedence over drag.
13111311 if (e.touches && e.touches.length >= 2) {
13121312 pinching = true;
13131313 dragging = false;
13141314 pinchStartDist = touchDistance(e);
13151315 pinchStartR = Rtarget;
13161316 lastInteraction = performance.now();
13171317 e.preventDefault();
13181318 return;
13191319 }
13201320 // Reset the drag-moved flag on every mousedown so a stale flag
13211321 // from a previous drag doesn't swallow the next click on a
13221322 // country/state dot.
13231323 dragMoved = false;
13241324 // Don't start a drag on a country dot -- those need to receive
13251325 // the click cleanly so the drill-down navigates.
13261326 if (e.target && e.target.closest && e.target.closest('.globe-country')) return;
13271327 dragging = true;
13281328 var p = getPoint(e);
13291329 dragStart = { x: p.x, y: p.y, lat0: lat0, lng0: lng0 };
13301330 svg.style.cursor = 'grabbing';
13311331 lastInteraction = performance.now();
13321332 e.preventDefault();
13331333 }
13341334 function onMove(e) {
13351335 // Pinch wins over drag while 2 fingers are on screen.
13361336 if (pinching && e.touches && e.touches.length >= 2) {
13371337 var d = touchDistance(e);
13381338 if (d > 0 && pinchStartDist > 0) {
13391339 var minR = Math.min(W, H) * 0.18;
13401340 var maxR = Math.min(W, H) * 5.0;
13411341 var scale = d / pinchStartDist;
13421342 Rtarget = Math.max(minR, Math.min(maxR, pinchStartR * scale));
13431343 lastInteraction = performance.now();
13441344 dirty = true;
13451345 }
13461346 e.preventDefault();
13471347 return;
13481348 }
13491349 if (!dragging) return;
13501350 var p = getPoint(e);
13511351 var dx = p.x - dragStart.x;
13521352 var dy = p.y - dragStart.y;
13531353 // 4px threshold before we consider it a "real" drag. Any click
13541354 // landing after a real drag is swallowed by the click handler.
13551355 if (!dragMoved && (dx*dx + dy*dy) > 16) dragMoved = true;
13561356 var sens = 0.32;
13571357 lng0 = dragStart.lng0 - dx * sens;
13581358 while (lng0 > 180) lng0 -= 360;
13591359 while (lng0 < -180) lng0 += 360;
13601360 lat0 = Math.max(-85, Math.min(85, dragStart.lat0 + dy * sens));
13611361 lastInteraction = performance.now();
13621362 dirty = true;
13631363 // Touch drag: stop the page from scrolling underneath.
13641364 if (e.touches) e.preventDefault();
13651365 }
13661366 function onUp(e) {
13671367 // Coming out of pinch -- if one finger remains, hand control back
13681368 // to a fresh single-finger drag from the remaining touch position.
13691369 if (pinching) {
13701370 pinching = false;
13711371 if (e && e.touches && e.touches.length === 1) {
13721372 var p = { x: e.touches[0].clientX, y: e.touches[0].clientY };
13731373 dragging = true;
13741374 dragMoved = false;
13751375 dragStart = { x: p.x, y: p.y, lat0: lat0, lng0: lng0 };
13761376 }
13771377 return;
13781378 }
13791379 if (dragging) { dragging = false; svg.style.cursor = 'grab'; }
13801380 }
13811381
13821382 svg.addEventListener('mousedown', onDown);
13831383 window.addEventListener('mousemove', onMove);
13841384 window.addEventListener('mouseup', onUp);
13851385 svg.addEventListener('touchstart', onDown, { passive: false });
13861386 window.addEventListener('touchmove', onMove, { passive: false });
13871387 window.addEventListener('touchend', onUp);
13881388
13891389 function onWheel(e) {
13901390 e.preventDefault();
13911391 e.stopPropagation();
13921392 var minR = Math.min(W, H) * 0.18;
13931393 // Allow zooming WELL past "sphere fills the canvas" -- once the
13941394 // sphere is bigger than the viewport you can pan around like
13951395 // dragging on a giant globe, which makes state/province labels
13961396 // legible. Bumped to 5x so the seller can zoom right down to a
13971397 // single country's subdivisions and still read every label.
13981398 var maxR = Math.min(W, H) * 5.0;
13991399 Rtarget = Math.max(minR, Math.min(maxR, Rtarget * (1 - e.deltaY * 0.0018)));
14001400 lastInteraction = performance.now();
14011401 }
14021402 svg.addEventListener('wheel', onWheel, { passive: false });
14031403
14041404 svg.addEventListener('click', function (e) {
14051405 if (dragMoved) { dragMoved = false; return; }
14061406 var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null;
14071407 if (!g) return;
14081408 var code = g.getAttribute('data-code');
14091409 if (!code) return;
14101410 // data-region is set for US-state dots only. Country dots have no
14111411 // region attribute, so country-level detail is loaded by default.
14121412 var region = g.getAttribute('data-region') || '';
14131413 openDrillModal(code, region);
14141414 });
14151415
14161416 // Document-level delegate for the "Top countries" leaderboard links
14171417 // (and anywhere else that opts in). Marking an element with
14181418 // data-country-drill="XX" routes its click through the modal
14191419 // instead of navigating. We attach once per page; multiple globe
14201420 // hosts share a single modal so this never double-fires.
14211421 if (!window.__globeDrillBound) {
14221422 window.__globeDrillBound = true;
14231423 document.addEventListener('click', function (e) {
14241424 var t = e.target && e.target.closest ? e.target.closest('[data-country-drill]') : null;
14251425 if (!t) return;
14261426 e.preventDefault();
14271427 var code = t.getAttribute('data-country-drill') || '';
14281428 var region = t.getAttribute('data-region') || '';
14291429 if (code) openDrillModal(code, region);
14301430 });
14311431 }
14321432
14331433 // Custom tooltip on hover.
14341434 svg.addEventListener('mousemove', function (e) {
14351435 var g = e.target && e.target.closest ? e.target.closest('.globe-country') : null;
14361436 if (!g) { tip.style.opacity = '0'; return; }
14371437 var rect = host.getBoundingClientRect();
14381438 var name = g.getAttribute('data-name') || '';
14391439 var n = g.getAttribute('data-count') || '0';
14401440 tip.innerHTML = name + ' &middot; <span style="color:#d97a86">' + n + '</span> visitors';
14411441 tip.style.left = (e.clientX - rect.left) + 'px';
14421442 tip.style.top = (e.clientY - rect.top) + 'px';
14431443 tip.style.opacity = '1';
14441444 });
14451445 svg.addEventListener('mouseleave', function () { tip.style.opacity = '0'; });
14461446
14471447 // ---- rAF loop: smooth zoom easing + idle spin + render-on-dirty -----
14481448 var lastSavedLat = lat0, lastSavedLng = lng0, lastSavedR = R;
14491449 function tick(now) {
14501450 if (Math.abs(R - Rtarget) > 0.2) {
14511451 R += (Rtarget - R) * 0.22;
14521452 dirty = true;
14531453 }
14541454 if (AUTOSPIN && !dragging && (now - lastInteraction) > 3000) {
14551455 lng0 -= 0.12;
14561456 if (lng0 < -180) lng0 += 360;
14571457 dirty = true;
14581458 }
14591459 if (dirty) { render(); dirty = false; }
14601460 // Persist state only when the user has actually moved something
14611461 // (drag finished, zoom settled, etc). Compared against the last
14621462 // saved snapshot so we don't write to storage every frame during
14631463 // a drag -- writes are cheap but not free.
14641464 if (!dragging &&
14651465 (Math.abs(lat0 - lastSavedLat) > 0.5 ||
14661466 Math.abs(lng0 - lastSavedLng) > 0.5 ||
14671467 Math.abs(R - lastSavedR) > 1)) {
14681468 lastSavedLat = lat0; lastSavedLng = lng0; lastSavedR = R;
14691469 saveState();
14701470 }
14711471 requestAnimationFrame(tick);
14721472 }
14731473 requestAnimationFrame(tick);
14741474 }
14751475
14761476 if (document.readyState === 'loading') {
14771477 document.addEventListener('DOMContentLoaded', initGlobes);
14781478 } else {
14791479 initGlobes();
14801480 }
14811481})();