/** * Hub landing globe — orthographic Canvas2D (?globe3d=N). * globe3d=0: flat SVG layers (index.php). * globe3d=1: legacy hybrid ortho front + mercator back + hub SVG grid trace. * globe3d=2: mercator land attempt + full-sphere grid (superseded). * globe3d=3: solid ocean, E–W parallels only (true latitude rings) — no baked grid bleed. * globe3d=4: solid ocean, full sphere grid (parallels + meridians). * globe3d=5: hub ellipse trace (deprecated — breaks under rotation). * globe3d=6: v4 math + latitude band fill (continuous arc paths). * globe3d=7: v4 math + wide centerline stroke bands (no fill quads). * globe3d=8: hub SVG ellipse trace (breaks under rotation — deprecated). * globe3d=9: v4 sphere grid only (thin E–W + N–S) — proper rotation baseline. * globe3d=10: v4 visible-arc → screen ellipse (hub ry), disk-clipped. * globe3d=11: v10 grid + geographic mercator-flat land (360° U-wrap). * ?latf=1.25&lngf=1&zy=1&zx=1&pp=0&pe=0 — land plate zoom + pole/limb lens pinch. * ?ax=5 — auto-spin speed factor (default 5 for landing hub). * ?cfa / ?cba / ?cbr / ?cbg / ?cbb / ?ceps — continent fill, border, coast (globe3d=11). * ?slx / ?sly / ?slx2 / ?sly2 / ?sld / ?sll / ?sli / ?slhi / ?slsh — sunlight (globe3d=11). * ?fogi / ?fogr / ?fogin — atmospheric fog around disk (globe3d=11). * ?globeFps / ?globeDpr — auto-spin FPS cap + max DPR (globe3d=11 perf). * ?globeGpu=0 — force CPU land pass; ?globeProfile=1 — console render timings. */ export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 }; const AUTO_SPIN_RAD_S = 0.035; /** Landing hub default auto-spin multiplier (?ax=). */ const GLOBE_SPIN_FACTOR_DEFAULT = 5; const DRAG_ROTATION_PER_PX = 0.004; /** globe3d=11 perf — cap DPR + auto-spin FPS (?globeDpr / ?globeFps). */ const GLOBE_DPR_MAX = 1; const GLOBE_SPIN_FPS = 1; const OCEAN_FALLBACK = { r: 22, g: 38, b: 60 }; /** Interior land fill — coast/body edge use base + ?cbr / ?cbg / ?cbb / ?cba. */ const CONTINENT_FILL_RGB = { r: 148, g: 172, b: 192 }; const FRONT_Z = 0.04; const LIMB_Z = 0.002; /** hub-logo-continents inner layer fit (continents-meta.json / extract_red_polygons.py). */ const CONTINENT_FLAT_W = 800; const CONTINENT_FLAT_H = 519; const CONTINENT_INNER_TX = 223.66088631984587; const CONTINENT_INNER_TY = 238.0; const CONTINENT_INNER_SX = 1.2408477842003853; const CONTINENT_INNER_SY = 1.2408477842003853; /** Map plate center longitude (extract_red_polygons.py CENTER_LON). */ const MERCATOR_LON_CENTER = (12 * Math.PI) / 180; /** Default land-plate zoom (?latf / ?lngf / ?zy / ?zx on globe3d=11). */ const LAND_ZOOM_DEFAULT = { latf: 1.25, lngf: 1, zy: 1, zx: 1, pp: 0.2, pe: 0 }; /** globe3d=11 continent paint (?cfa / ?cba / ?cbr / ?cbg / ?cbb / ?ceps). */ const CONTINENT_STYLE_DEFAULT = { fillAlpha: 92, borderAlpha: 248, borderBoostR: 30, borderBoostG: 22, borderBoostB: 12, coastEps: 0.0045, }; function resolveContinentStyle(raw) { const r = raw && typeof raw === 'object' ? raw : {}; return { fillAlpha: clampZoomNum(r.fillAlpha, 0, 255, CONTINENT_STYLE_DEFAULT.fillAlpha), borderAlpha: clampZoomNum(r.borderAlpha, 0, 255, CONTINENT_STYLE_DEFAULT.borderAlpha), borderBoostR: clampZoomNum(r.borderBoostR, 0, 96, CONTINENT_STYLE_DEFAULT.borderBoostR), borderBoostG: clampZoomNum(r.borderBoostG, 0, 96, CONTINENT_STYLE_DEFAULT.borderBoostG), borderBoostB: clampZoomNum(r.borderBoostB, 0, 96, CONTINENT_STYLE_DEFAULT.borderBoostB), coastEps: clampZoomNum(r.coastEps, 0.001, 0.05, CONTINENT_STYLE_DEFAULT.coastEps), }; } /** globe3d=11 sunlight (?slx / ?sly / ?slx2 / ?sly2 / ?sld / ?sll / ?sli / ?slhi / ?slsh). */ const SUNLIGHT_STYLE_DEFAULT = { lx: -0.92, ly: -0.88, lx2: 0.95, ly2: 0.92, dir: null, span: 1.85, intensity: 1, highlight: 0.75, shadow: 1.25, }; function resolveSunlightStyle(raw) { const r = raw && typeof raw === 'object' ? raw : {}; const hasDir = typeof r.dir === 'number' && isFinite(r.dir); return { lx: clampZoomNum(r.lx, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.lx), ly: clampZoomNum(r.ly, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.ly), lx2: hasDir ? null : clampZoomNum(r.lx2, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.lx2), ly2: hasDir ? null : clampZoomNum(r.ly2, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.ly2), dir: hasDir ? r.dir : null, span: clampZoomNum(r.span, 0.3, 4, SUNLIGHT_STYLE_DEFAULT.span), intensity: clampZoomNum(r.intensity, 0, 3, SUNLIGHT_STYLE_DEFAULT.intensity), highlight: clampZoomNum(r.highlight, 0, 1, SUNLIGHT_STYLE_DEFAULT.highlight), shadow: clampZoomNum(r.shadow, 0, 1, SUNLIGHT_STYLE_DEFAULT.shadow), }; } /** globe3d=11 rim fog (?fogi / ?fogr / ?fogin). */ const FOG_STYLE_DEFAULT = { intensity: 2.7, outer: 0.15, inner: 0.7, }; function resolveFogStyle(raw) { const r = raw && typeof raw === 'object' ? raw : {}; return { intensity: clampZoomNum(r.intensity, 0, 3, FOG_STYLE_DEFAULT.intensity), outer: clampZoomNum(r.outer, 0, 2, FOG_STYLE_DEFAULT.outer), inner: clampZoomNum(r.inner, 0.7, 1.05, FOG_STYLE_DEFAULT.inner), }; } function fogPadCss(rect, fogStyle) { const fog = fogStyle || resolveFogStyle(null); if (fog.intensity <= 0 || fog.outer <= 0) { return 0; } return rect.r * fog.outer; } function sunlightGradientEnds(style, cx, cy, r) { const x1 = cx + style.lx * r; const y1 = cy + style.ly * r; let x2; let y2; if (style.dir != null) { const rad = (style.dir * Math.PI) / 180; x2 = x1 + Math.cos(rad) * style.span * r; y2 = y1 + Math.sin(rad) * style.span * r; } else { x2 = cx + style.lx2 * r; y2 = cy + style.ly2 * r; } return { x1: x1, y1: y1, x2: x2, y2: y2 }; } function clampZoomNum(v, min, max, fallback) { if (typeof v !== 'number' || !isFinite(v)) { return fallback; } return Math.max(min, Math.min(max, v)); } function resolveLandZoom(raw) { const r = raw && typeof raw === 'object' ? raw : {}; return { latf: clampZoomNum(r.latf, 0.05, 4, LAND_ZOOM_DEFAULT.latf), lngf: clampZoomNum(r.lngf, 0.05, 4, LAND_ZOOM_DEFAULT.lngf), zy: clampZoomNum(r.zy, 0.05, 4, LAND_ZOOM_DEFAULT.zy), zx: clampZoomNum(r.zx, 0.05, 4, LAND_ZOOM_DEFAULT.zx), pp: clampZoomNum(r.pp, 0, 0.95, LAND_ZOOM_DEFAULT.pp), pe: clampZoomNum(r.pe, 0, 0.95, LAND_ZOOM_DEFAULT.pe), }; } /** URL ?ax=N — auto-spin speed multiplier (landing default 5). */ function resolveSpinFactor(raw) { if (typeof raw !== 'number' || !isFinite(raw)) { return GLOBE_SPIN_FACTOR_DEFAULT; } return Math.max(0.05, Math.min(8, raw)); } function resolveGlobePerf(raw) { const r = raw && typeof raw === 'object' ? raw : {}; return { dprMax: clampZoomNum(r.dprMax, 0.75, 2, GLOBE_DPR_MAX), spinFps: clampZoomNum(r.spinFps, 1, 60, GLOBE_SPIN_FPS), useGpu: r.useGpu !== false, profile: !!r.profile, }; } /** * Land plate sample remap (globe3d=11 URL tunables): * latf/lngf — linear zoom; zy/zx — equator/prime-meridian mag (1 = uniform). * pp — pole lens pinch (0 = off): pulls N/S sample toward equator near poles. * pe — disk limb pinch (0 = off): uses radial distance on ortho disk, not geo lon. */ function landZoomLonLat(lon, lat, zoom, tex) { const latT = Math.min(1, Math.abs(lat) / (Math.PI / 2)); const lonT = Math.min(1, Math.abs(lon) / Math.PI); const rimT = Math.min(1, Math.sqrt(tex.x * tex.x + tex.y * tex.y)); const latMul = zoom.latf * (zoom.zy * (1 - latT * latT) + latT * latT); const lonMul = zoom.lngf * (zoom.zx * (1 - lonT * lonT) + lonT * lonT); const poleLens = 1 - zoom.pp * latT * latT; const edgeLens = 1 - zoom.pe * rimT * rimT; return { lon: wrapLon(lon * lonMul * edgeLens + MERCATOR_LON_CENTER), lat: Math.max(-1.52, Math.min(1.52, lat * latMul * poleLens)), }; } const RASTER_SCALE = 2; const AXIS_STEPS = 160; const SPHERE_AXIS_STEPS = 180; /** Latitudes from hub grid overlay ellipse centers (meridian x = cx). */ function hubParallelLat(hubCy) { const texY = (GLOBE_VIEW.cy - hubCy) / GLOBE_VIEW.r; return Math.asin(Math.max(-1, Math.min(1, texY))); } const SPHERE_PARALLELS = [ { lat: 0, bright: true }, { lat: hubParallelLat(392), bright: false }, { lat: hubParallelLat(728), bright: false }, ]; /** Longitudes at equator from hub N–S meridian artwork (x=620 / x=820). */ const SPHERE_MERIDIANS = [ { lon: 0, prime: true }, { lon: Math.atan2((620 - 720) / 322, Math.sqrt(Math.max(0, 1 - Math.pow((620 - 720) / 322, 2)))), prime: false }, { lon: Math.atan2((820 - 720) / 322, Math.sqrt(Math.max(0, 1 - Math.pow((820 - 720) / 322, 2)))), prime: false }, ]; /** hub-logo-fragment-globe-grid-overlay.svg — E–W parallels. */ const HUB_EW_PARALLELS = [ { cx: 720, cy: 560, rx: 318, ry: 11, bright: true }, { cx: 720, cy: 392, rx: 260, ry: 39, bright: false }, { cx: 720, cy: 728, rx: 260, ry: 39, bright: false }, ]; /** Same SVG — N–S meridian artwork (globe3d=1 static trace; beziers break under Y spin). */ const HUB_NS_MERIDIANS = [ { prime: true, points: sampleLinePoints(720, 248, 720, 872, AXIS_STEPS) }, { prime: false, points: sampleQuadPoints(620, 248, 358, 560, 620, 872, AXIS_STEPS) }, { prime: false, points: sampleQuadPoints(820, 248, 1082, 560, 820, 872, AXIS_STEPS) }, ]; /** globe3d=11 — ±π/4 face zones; antipodal pairs so 3 stay on the front cap at any rotY. */ const HUB_MERIDIAN_BANDS = [ { lon: -Math.PI / 4, prime: false }, { lon: 0, prime: true }, { lon: Math.PI / 4, prime: false }, { lon: (3 * Math.PI) / 4, prime: false }, { lon: Math.PI, prime: false }, { lon: (-3 * Math.PI) / 4, prime: false }, ]; /** Include limb/pole (z=0); still cull back hemisphere (z < 0). */ const MERIDIAN_VIEW_Z = -0.0001; function sampleLinePoints(x0, y0, x1, y1, steps) { const pts = []; for (let i = 0; i <= steps; i++) { const t = i / steps; pts.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t }); } return pts; } function sampleQuadPoints(x0, y0, cx, cy, x1, y1, steps) { const pts = []; for (let i = 0; i <= steps; i++) { const t = i / steps; const u = 1 - t; pts.push({ x: u * u * x0 + 2 * u * t * cx + t * t * x1, y: u * u * y0 + 2 * u * t * cy + t * t * y1, }); } return pts; } function hubAsset(rel, build) { const link = document.querySelector('link[href*="hub.css"]'); const base = link ? (link.getAttribute('href') || '').replace(/[^/]*$/, '') : ''; const sep = rel.indexOf('?') >= 0 ? '&' : '?'; return base + rel + sep + 'v=' + build; } export function globeCoverRect() { const vw = window.innerWidth; const vh = window.innerHeight; const scale = Math.max(vw / GLOBE_VIEW.imgW, vh / GLOBE_VIEW.imgH); const drawW = GLOBE_VIEW.imgW * scale; const drawH = GLOBE_VIEW.imgH * scale; const offX = (vw - drawW) / 2; const offY = (vh - drawH) / 2; const left = offX + (GLOBE_VIEW.cx - GLOBE_VIEW.r) * scale; const top = offY + (GLOBE_VIEW.cy - GLOBE_VIEW.r) * scale; const size = GLOBE_VIEW.r * 2 * scale; return { left, top, width: size, height: size, scale, cx: GLOBE_VIEW.cx * scale + offX, cy: GLOBE_VIEW.cy * scale + offY, r: GLOBE_VIEW.r * scale, rim: GLOBE_VIEW.rim * scale, }; } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('image load failed: ' + url)); img.src = url; }); } function rasterizeImage(img, w, h, scale) { const s = scale || 1; const rw = Math.max(1, Math.round(w * s)); const rh = Math.max(1, Math.round(h * s)); const c = document.createElement('canvas'); c.width = rw; c.height = rh; const ctx = c.getContext('2d', { willReadFrequently: true }); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, 0, 0, rw, rh); return { data: ctx.getImageData(0, 0, rw, rh).data, w: rw, h: rh }; } function sampleImageDataBilinearWrapU(imgData, w, h, fx, fy) { const y = Math.max(0, Math.min(h - 1.001, fy)); let x = fx % w; if (x < 0) { x += w; } const x0 = x | 0; const tx = x - x0; const x1 = (x0 + 1) % w; const y0 = y | 0; const y1 = Math.min(h - 1, y0 + 1); const ty = y - y0; const i00 = (y0 * w + x0) * 4; const i10 = (y0 * w + x1) * 4; const i01 = (y1 * w + x0) * 4; const i11 = (y1 * w + x1) * 4; const out = [0, 0, 0, 0]; for (let c = 0; c < 4; c++) { out[c] = Math.round( imgData[i00 + c] * (1 - tx) * (1 - ty) + imgData[i10 + c] * tx * (1 - ty) + imgData[i01 + c] * (1 - tx) * ty + imgData[i11 + c] * tx * ty ); } return out; } function sampleImageDataBilinear(imgData, w, h, fx, fy) { const x = Math.max(0, Math.min(w - 1.001, fx)); const y = Math.max(0, Math.min(h - 1.001, fy)); const x0 = x | 0; const y0 = y | 0; const x1 = Math.min(w - 1, x0 + 1); const y1 = Math.min(h - 1, y0 + 1); const tx = x - x0; const ty = y - y0; const i00 = (y0 * w + x0) * 4; const i10 = (y0 * w + x1) * 4; const i01 = (y1 * w + x0) * 4; const i11 = (y1 * w + x1) * 4; const out = [0, 0, 0, 0]; for (let c = 0; c < 4; c++) { out[c] = Math.round( imgData[i00 + c] * (1 - tx) * (1 - ty) + imgData[i10 + c] * tx * (1 - ty) + imgData[i01 + c] * (1 - tx) * ty + imgData[i11 + c] * tx * ty ); } return out; } function inverseRotateY(nx, ny, nz, rotY) { const cosR = Math.cos(rotY); const sinR = Math.sin(rotY); return { x: nx * cosR - nz * sinR, y: ny, z: nx * sinR + nz * cosR, }; } function bodyToTex(body) { return { x: body.x, y: -body.y, z: body.z }; } function bodyToView(body, rotY) { const cosR = Math.cos(rotY); const sinR = Math.sin(rotY); return { x: body.x * cosR + body.z * sinR, y: body.y, z: -body.x * sinR + body.z * cosR, }; } /** Hub disk pixel → unit-sphere body (front hemisphere, matches globe3d=0). */ function hubPixelToBody(hpX, hpY) { const texX = (hpX - GLOBE_VIEW.cx) / GLOBE_VIEW.r; const texY = (GLOBE_VIEW.cy - hpY) / GLOBE_VIEW.r; const rr = texX * texX + texY * texY; if (rr > 1.001) { return null; } return { x: texX, y: -texY, z: Math.sqrt(Math.max(0, 1 - rr)), }; } function hubPointsToBodyRing(points) { const out = []; for (let i = 0; i < points.length; i++) { const body = hubPixelToBody(points[i].x, points[i].y); if (body) { out.push(body); } } return out; } function ellipseBodyRing(ellipse, steps) { const out = []; for (let i = 0; i <= steps; i++) { const a = (i / steps) * Math.PI * 2; out.push( hubPixelToBody(ellipse.cx + ellipse.rx * Math.cos(a), ellipse.cy + ellipse.ry * Math.sin(a)) ); } return out; } function parallelStyle(bright, scale, z, limbZ) { const zCut = limbZ != null ? limbZ : FRONT_Z; const t = Math.max(0, Math.min(1, (z - zCut) / (0.95 - zCut))); if (t <= 0) { return null; } const alpha = bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t; const rgb = bright ? '219,231,246' : '231,236,243'; return { color: 'rgba(' + rgb + ',' + alpha.toFixed(3) + ')', width: Math.max(bright ? 3 : 2, scale * (bright ? 0.009 : 0.006)) * (0.5 + 0.5 * t), }; } function continentBorderRgba(cs, base) { const b = base || CONTINENT_FILL_RGB; return { r: Math.min(255, b.r + cs.borderBoostR), g: Math.min(255, b.g + cs.borderBoostG), b: Math.min(255, b.b + cs.borderBoostB), a: cs.borderAlpha / 255, }; } /** globe3d=11 — shared thin grid stroke (parallels + meridians). */ function gridLineStrokeStyle(bright, layoutScale, t) { const rgb = bright ? '219,231,246' : '231,236,243'; const alpha = bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t; const lineW = Math.max(bright ? 2.5 : 2, layoutScale * 0.0075) * (0.55 + 0.45 * t); return { rgb: rgb, alpha: alpha, lineW: lineW }; } function meridianStyle(prime, scale, z, limbZ) { const zCut = limbZ != null ? limbZ : FRONT_Z; const t = Math.max(0, Math.min(1, (z - zCut) / (0.95 - zCut))); if (t <= 0) { return null; } const alpha = prime ? 0.5 + 0.48 * t : 0.3 + 0.45 * t; return { color: 'rgba(238,245,255,' + alpha.toFixed(3) + ')', width: Math.max(prime ? 3 : 2.5, scale * (prime ? 0.0095 : 0.008)) * (0.45 + 0.55 * t), }; } /** globe3d=11 — thin meridian matching E–W parallel stroke. */ function strokeMeridianHubLine(ctx, cx, cy, r, rotY, mer, layoutScale) { const ring = meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS); strokeBodyRing( ctx, cx, cy, r, rotY, ring, function (z) { const zVis = Math.max(0, z); const t = Math.max(0.15, Math.min(1, zVis / 0.95)); const s = gridLineStrokeStyle(mer.prime, layoutScale, t); return { color: 'rgba(' + s.rgb + ',' + s.alpha.toFixed(3) + ')', width: s.lineW, }; }, MERIDIAN_VIEW_Z ); } function strokeBodyRing(ctx, cx, cy, r, rotY, bodies, styleAt, limbZ) { let run = []; const zCut = limbZ != null ? limbZ : FRONT_Z; function flush() { if (run.length < 2) { run = []; return; } let zSum = 0; for (let i = 0; i < run.length; i++) { zSum += run[i].z; } const style = styleAt(zSum / run.length); if (!style) { run = []; return; } ctx.beginPath(); ctx.moveTo(run[0].sx, run[0].sy); for (let i = 1; i < run.length; i++) { ctx.lineTo(run[i].sx, run[i].sy); } ctx.strokeStyle = style.color; ctx.lineWidth = style.width; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); run = []; } for (let i = 0; i < bodies.length; i++) { if (!bodies[i]) { flush(); continue; } const view = bodyToView(bodies[i], rotY); if (view.z <= zCut) { flush(); continue; } run.push({ sx: cx + view.x * r, sy: cy - view.y * r, z: view.z, }); } flush(); } function parallelBodyRingAtLat(lat, steps) { const out = []; const cosLat = Math.cos(lat); const sinLat = Math.sin(lat); for (let i = 0; i <= steps; i++) { const lon = -Math.PI + (2 * Math.PI * i) / steps; out.push({ x: cosLat * Math.sin(lon), y: sinLat, z: cosLat * Math.cos(lon), }); } return out; } function meridianBodyRingAtLon(lon, steps) { const out = []; for (let i = 0; i <= steps; i++) { const lat = -Math.PI / 2 + (Math.PI * i) / steps; const cosLat = Math.cos(lat); out.push({ x: cosLat * Math.sin(lon), y: Math.sin(lat), z: cosLat * Math.cos(lon), }); } return out; } function parallelBandHalfWidth(par) { const hubRy = par.bright ? HUB_EW_PARALLELS[0].ry : HUB_EW_PARALLELS[1].ry; return hubRy / GLOBE_VIEW.r; } function parallelBandStyle(par, z, bandPx, layer) { const t = Math.max(0, Math.min(1, (z - LIMB_Z) / (0.95 - LIMB_Z))); if (t <= 0) { return null; } const rgb = par.bright ? '219,231,246' : '231,236,243'; if (layer === 'halo') { return { color: 'rgba(' + rgb + ',' + (0.1 + 0.2 * t).toFixed(3) + ')', width: bandPx * 1.08, }; } return { color: 'rgba(' + rgb + ',' + (par.bright ? 0.4 + 0.52 * t : 0.24 + 0.4 * t).toFixed(3) + ')', width: bandPx * (0.58 + 0.42 * t), }; } /** One filled ribbon per visible arc — no per-longitude quads (avoids comb artifacts). */ function fillParallelBandPath(ctx, cx, cy, r, rotY, par) { const lat = par.lat; const halfW = parallelBandHalfWidth(par); const latLo = Math.max(-Math.PI / 2 + 0.02, lat - halfW); const latHi = Math.min(Math.PI / 2 - 0.02, lat + halfW); const outer = parallelBodyRingAtLat(latHi, SPHERE_AXIS_STEPS); const inner = parallelBodyRingAtLat(latLo, SPHERE_AXIS_STEPS); const n = Math.min(outer.length, inner.length); const rgb = par.bright ? '219,231,246' : '231,236,243'; let runO = []; let runI = []; function flushFill() { if (runO.length < 2) { runO = []; runI = []; return; } let zSum = 0; for (let k = 0; k < runO.length; k++) { zSum += runO[k].z; } const zAvg = zSum / runO.length; const t = Math.max(0, Math.min(1, (zAvg - LIMB_Z) / (0.95 - LIMB_Z))); if (t <= 0) { runO = []; runI = []; return; } const alpha = (par.bright ? 0.18 + 0.52 * t : 0.1 + 0.36 * t).toFixed(3); ctx.beginPath(); ctx.moveTo(runO[0].sx, runO[0].sy); for (let k = 1; k < runO.length; k++) { ctx.lineTo(runO[k].sx, runO[k].sy); } for (let k = runI.length - 1; k >= 0; k--) { ctx.lineTo(runI[k].sx, runI[k].sy); } ctx.closePath(); ctx.fillStyle = 'rgba(' + rgb + ',' + alpha + ')'; ctx.fill(); runO = []; runI = []; } for (let i = 0; i < n; i++) { const vo = bodyToView(outer[i], rotY); const vi = bodyToView(inner[i], rotY); if (vo.z <= LIMB_Z || vi.z <= LIMB_Z) { flushFill(); continue; } runO.push({ sx: cx + vo.x * r, sy: cy - vo.y * r, z: vo.z }); runI.push({ sx: cx + vi.x * r, sy: cy - vi.y * r, z: vi.z }); } flushFill(); strokeBodyRing( ctx, cx, cy, r, rotY, parallelBodyRingAtLat(lat, SPHERE_AXIS_STEPS), function (z) { const style = parallelStyle(par.bright, 1, z, LIMB_Z); if (!style) { return null; } return { color: style.color, width: Math.max(style.width, (par.bright ? 1.8 : 1.4)), }; }, LIMB_Z ); } /** Wide dual stroke on centerline — globe3d=7. */ function strokeParallelBandWide(ctx, cx, cy, r, rotY, par, layoutScale) { const halfW = parallelBandHalfWidth(par); const bandPx = Math.max(par.bright ? 6 : 10, 2 * halfW * r); const ring = parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS); strokeBodyRing( ctx, cx, cy, r, rotY, ring, function (z) { return parallelBandStyle(par, z, bandPx, 'halo'); }, LIMB_Z ); strokeBodyRing( ctx, cx, cy, r, rotY, ring, function (z) { return parallelBandStyle(par, z, bandPx, 'core'); }, LIMB_Z ); } /** globe3d=6 — v4 sphere model with volumetric E–W bands. */ function drawSphereGridBanded(ctx, cx, cy, r, rotY, layoutScale, opts) { const parallelsOnly = !!(opts && opts.parallelsOnly); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); SPHERE_PARALLELS.forEach(function (par) { fillParallelBandPath(ctx, cx, cy, r, rotY, par); }); if (!parallelsOnly) { SPHERE_MERIDIANS.forEach(function (mer) { strokeBodyRing( ctx, cx, cy, r, rotY, meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS), function (z) { return meridianStyle(mer.prime, layoutScale, z, LIMB_Z); }, LIMB_Z ); }); } ctx.restore(); } /** Hub ellipse centerline on sphere, wide stroke — stays elliptical under Y rotation (globe3d=8). */ function strokeHubEllipseParallel(ctx, cx, cy, r, rotY, ellipse, layoutScale) { const scale = r / GLOBE_VIEW.r; const bandPx = Math.max(ellipse.bright ? 5 : 8, 2 * ellipse.ry * scale); const par = { bright: ellipse.bright }; const ring = ellipseBodyRing(ellipse, AXIS_STEPS); strokeBodyRing( ctx, cx, cy, r, rotY, ring, function (z) { return parallelBandStyle(par, z, bandPx, 'halo'); }, LIMB_Z ); strokeBodyRing( ctx, cx, cy, r, rotY, ring, function (z) { return parallelBandStyle(par, z, bandPx, 'core'); }, LIMB_Z ); } /** globe3d=8 — hub E–W ellipses on sphere; v4 N–S meridians. */ function drawHubEllipseGridRotated(ctx, cx, cy, r, rotY, layoutScale) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); HUB_EW_PARALLELS.forEach(function (ellipse) { strokeHubEllipseParallel(ctx, cx, cy, r, rotY, ellipse, layoutScale); }); SPHERE_MERIDIANS.forEach(function (mer) { strokeBodyRing( ctx, cx, cy, r, rotY, meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS), function (z) { return meridianStyle(mer.prime, layoutScale, z, LIMB_Z); }, LIMB_Z ); }); ctx.restore(); } function hubRyForParallel(par) { if (par.bright) { return HUB_EW_PARALLELS[0].ry; } return par.lat > 0 ? HUB_EW_PARALLELS[1].ry : HUB_EW_PARALLELS[2].ry; } /** v4 visible arc bounds → 2D ellipse (curved ends via disk clip). */ function drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par) { let xMin = Infinity; let xMax = -Infinity; let zSum = 0; let n = 0; const ring = parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS); for (let i = 0; i < ring.length; i++) { const view = bodyToView(ring[i], rotY); if (view.z <= LIMB_Z) { continue; } xMin = Math.min(xMin, view.x); xMax = Math.max(xMax, view.x); zSum += view.z; n++; } if (n < 2 || xMax <= xMin) { return; } const t = Math.max(0, Math.min(1, (zSum / n - LIMB_Z) / (0.95 - LIMB_Z))); const rx = ((xMax - xMin) / 2) * r; const ry = hubRyForParallel(par) * (r / GLOBE_VIEW.r); if (rx < 0.5 || ry < 0.5) { return; } const ecx = cx + ((xMin + xMax) / 2) * r; const ecy = cy - Math.sin(par.lat) * r; const layoutScale = r / GLOBE_VIEW.r; const stroke = gridLineStrokeStyle(par.bright, layoutScale, t); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); ctx.beginPath(); ctx.ellipse(ecx, ecy, rx, ry, 0, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(' + stroke.rgb + ',' + stroke.alpha.toFixed(3) + ')'; ctx.lineWidth = stroke.lineW; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore(); } /** @deprecated screen-Y strips — flat rectangles; use drawParallelScreenEllipse. */ function fillParallelScreenStrip(ctx, cx, cy, r, rotY, par) { const halfPx = (par.bright ? HUB_EW_PARALLELS[0].ry : HUB_EW_PARALLELS[1].ry) * (r / GLOBE_VIEW.r); const ring = parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS); const rgb = par.bright ? '219,231,246' : '231,236,243'; let run = []; function flushFill() { if (run.length < 2) { run = []; return; } let zSum = 0; for (let k = 0; k < run.length; k++) { zSum += run[k].z; } const zAvg = zSum / run.length; const t = Math.max(0, Math.min(1, (zAvg - LIMB_Z) / (0.95 - LIMB_Z))); if (t <= 0) { run = []; return; } const alpha = (par.bright ? 0.16 + 0.48 * t : 0.09 + 0.34 * t).toFixed(3); ctx.beginPath(); for (let k = 0; k < run.length; k++) { ctx.lineTo(run[k].sx, run[k].sy - halfPx); } for (let k = run.length - 1; k >= 0; k--) { ctx.lineTo(run[k].sx, run[k].sy + halfPx); } ctx.closePath(); ctx.fillStyle = 'rgba(' + rgb + ',' + alpha + ')'; ctx.fill(); run = []; } for (let i = 0; i < ring.length; i++) { const view = bodyToView(ring[i], rotY); if (view.z <= LIMB_Z) { flushFill(); continue; } run.push({ sx: cx + view.x * r, sy: cy - view.y * r, z: view.z, }); } flushFill(); } /** globe3d=10 — v4 E–W screen strips + v4 meridians. */ function drawSphereGridScreenStrip(ctx, cx, cy, r, rotY, layoutScale) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); SPHERE_PARALLELS.forEach(function (par) { drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par); }); SPHERE_MERIDIANS.forEach(function (mer) { strokeBodyRing( ctx, cx, cy, r, rotY, meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS), function (z) { return meridianStyle(mer.prime, layoutScale, z, LIMB_Z); }, LIMB_Z ); }); ctx.restore(); } /** globe3d=11 — v10 E–W ellipses + hub-x great-circle meridians (pole to pole). */ function drawSphereGridV11(ctx, cx, cy, r, rotY, layoutScale) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); SPHERE_PARALLELS.forEach(function (par) { drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par); }); HUB_MERIDIAN_BANDS.forEach(function (mer) { strokeMeridianHubLine(ctx, cx, cy, r, rotY, mer, layoutScale); }); ctx.restore(); } /** globe3d=7 — v4 sphere model, wide stroke bands (no path fill). */ function drawSphereGridWideStroke(ctx, cx, cy, r, rotY, layoutScale, opts) { const parallelsOnly = !!(opts && opts.parallelsOnly); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); SPHERE_PARALLELS.forEach(function (par) { strokeParallelBandWide(ctx, cx, cy, r, rotY, par, layoutScale); }); if (!parallelsOnly) { SPHERE_MERIDIANS.forEach(function (mer) { strokeBodyRing( ctx, cx, cy, r, rotY, meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS), function (z) { return meridianStyle(mer.prime, layoutScale, z, LIMB_Z); }, LIMB_Z ); }); } ctx.restore(); } /** Full-sphere grid rings (globe3d>=4). */ function drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, opts) { const parallelsOnly = !!(opts && opts.parallelsOnly); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); SPHERE_PARALLELS.forEach(function (par) { strokeBodyRing( ctx, cx, cy, r, rotY, parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS), function (z) { return parallelStyle(par.bright, layoutScale, z, LIMB_Z); }, LIMB_Z ); }); if (!parallelsOnly) { SPHERE_MERIDIANS.forEach(function (mer) { strokeBodyRing( ctx, cx, cy, r, rotY, meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS), function (z) { return meridianStyle(mer.prime, layoutScale, z, LIMB_Z); }, LIMB_Z ); }); } ctx.restore(); } /** Hub SVG ellipses traced on sphere (globe3d=5) — matches static art at yaw 0. */ function drawHubParallelGrid(ctx, cx, cy, r, rotY, layoutScale) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); HUB_EW_PARALLELS.forEach(function (ellipse) { strokeBodyRing(ctx, cx, cy, r, rotY, ellipseBodyRing(ellipse, AXIS_STEPS), function (z) { return parallelStyle(ellipse.bright, layoutScale, z, LIMB_Z); }, LIMB_Z); }); ctx.restore(); } /** Hub SVG grid: E–W ellipses + N–S meridian paths (globe3d=1). */ function drawHubGrid(ctx, cx, cy, r, rotY, layoutScale, opts) { const parallelsOnly = !!(opts && opts.parallelsOnly); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); HUB_EW_PARALLELS.forEach(function (ellipse) { strokeBodyRing(ctx, cx, cy, r, rotY, ellipseBodyRing(ellipse, AXIS_STEPS), function (z) { return parallelStyle(ellipse.bright, layoutScale, z); }); }); if (!parallelsOnly) { HUB_NS_MERIDIANS.forEach(function (mer) { strokeBodyRing(ctx, cx, cy, r, rotY, hubPointsToBodyRing(mer.points), function (z) { return meridianStyle(mer.prime, layoutScale, z); }); }); } ctx.restore(); } function drawGlobeGrid(ctx, cx, cy, r, rotY, layoutScale, renderVersion) { if (renderVersion === 3) { drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: true }); return; } if (renderVersion === 4) { drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; } if (renderVersion === 5) { drawHubParallelGrid(ctx, cx, cy, r, rotY, layoutScale); return; } if (renderVersion === 6) { drawSphereGridBanded(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; } if (renderVersion === 7) { drawSphereGridWideStroke(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; } if (renderVersion === 8) { drawHubEllipseGridRotated(ctx, cx, cy, r, rotY, layoutScale); return; } if (renderVersion === 9) { drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; } if (renderVersion === 10) { drawSphereGridScreenStrip(ctx, cx, cy, r, rotY, layoutScale); return; } if (renderVersion === 11) { drawSphereGridV11(ctx, cx, cy, r, rotY, layoutScale); return; } if (renderVersion >= 2) { drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; } drawHubGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); } /** Sunlight overlay — screen highlight + multiply shadow (globe3d=11). */ function drawGlobeSunlight(ctx, cx, cy, r, sunlightStyle) { const sl = sunlightStyle || resolveSunlightStyle(null); if (sl.intensity <= 0) { return; } const ends = sunlightGradientEnds(sl, cx, cy, r); const hi = sl.highlight * sl.intensity; const sh = sl.shadow * sl.intensity; const rect = { x: cx - r, y: cy - r, w: r * 2, h: r * 2 }; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); const gLight = ctx.createLinearGradient(ends.x1, ends.y1, ends.x2, ends.y2); gLight.addColorStop(0, 'rgba(196,220,255,' + Math.min(1, hi * 0.95).toFixed(3) + ')'); gLight.addColorStop(0.38, 'rgba(140,170,210,' + Math.min(1, hi * 0.28).toFixed(3) + ')'); gLight.addColorStop(0.58, 'rgba(255,255,255,0)'); gLight.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = gLight; ctx.globalCompositeOperation = 'screen'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); const gDark = ctx.createLinearGradient(ends.x1, ends.y1, ends.x2, ends.y2); gDark.addColorStop(0, 'rgba(255,255,255,0)'); gDark.addColorStop(0.48, 'rgba(255,255,255,0)'); gDark.addColorStop(0.78, 'rgba(18,28,44,' + Math.min(1, sh * 0.45).toFixed(3) + ')'); gDark.addColorStop(1, 'rgba(0,0,0,' + Math.min(1, sh).toFixed(3) + ')'); ctx.fillStyle = gDark; ctx.globalCompositeOperation = 'multiply'; ctx.fillRect(rect.x, rect.y, rect.w, rect.h); ctx.globalCompositeOperation = 'source-over'; ctx.restore(); } /** Atmospheric halo outside the disk (globe3d=11). Canvas must include fogPadCss margin. */ function drawGlobeFog(ctx, cx, cy, r, fogStyle) { const fog = fogStyle || resolveFogStyle(null); if (fog.intensity <= 0) { return; } const outerR = r * (1 + fog.outer); const innerR = r * fog.inner; const peakA = (0.32 * fog.intensity).toFixed(3); const midA = (0.2 * fog.intensity).toFixed(3); ctx.save(); ctx.globalCompositeOperation = 'screen'; const g = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR); g.addColorStop(0, 'rgba(88,128,168,0)'); g.addColorStop(0.45, 'rgba(64,98,138,' + midA + ')'); g.addColorStop(0.78, 'rgba(48,78,112,' + peakA + ')'); g.addColorStop(1, 'rgba(18,30,48,0)'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, outerR, 0, Math.PI * 2); ctx.fill(); ctx.globalCompositeOperation = 'source-over'; ctx.restore(); } /** Limb ring at disk radius r — earth/fog boundary (?cba / ?cbr… + grid halo for contrast). */ function drawGlobeBodyEdge(ctx, cx, cy, r, continentStyle, layoutScale) { const cs = continentStyle || resolveContinentStyle(null); const edge = continentBorderRgba(cs, CONTINENT_FILL_RGB); const grid = gridLineStrokeStyle(true, layoutScale, 1); const lineW = Math.max(2.5, layoutScale * 0.008); const core = 'rgba(' + edge.r + ',' + edge.g + ',' + edge.b + ',' + edge.a.toFixed(3) + ')'; ctx.save(); ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(' + grid.rgb + ',0.38)'; ctx.lineWidth = lineW * 3.2; ctx.lineCap = 'round'; ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(' + edge.r + ',' + edge.g + ',' + edge.b + ',' + Math.min(1, edge.a * 0.55).toFixed(3) + ')'; ctx.lineWidth = lineW * 1.65; ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.strokeStyle = core; ctx.lineWidth = lineW; ctx.stroke(); ctx.restore(); } function drawOceanRim(ctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion) { if (renderVersion === 11) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, rim, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2, true); ctx.clip(); const g = ctx.createRadialGradient(cx, cy, r * 0.96, cx, cy, rim); g.addColorStop(0, 'rgba(36,58,88,0.55)'); g.addColorStop(0.55, 'rgba(24,42,66,0.88)'); g.addColorStop(1, 'rgba(12,22,36,0.96)'); ctx.fillStyle = g; ctx.fill(); ctx.restore(); return; } if (renderVersion >= 3) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, rim, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2, true); ctx.clip(); const g = ctx.createRadialGradient(cx, cy, r, cx, cy, rim); g.addColorStop(0, 'rgba(28,48,74,0.98)'); g.addColorStop(1, 'rgba(14,24,38,0.92)'); ctx.fillStyle = g; ctx.fill(); ctx.restore(); return; } drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale); } function sampleHub(layer, tex) { const hp = { x: GLOBE_VIEW.cx + tex.x * GLOBE_VIEW.r, y: GLOBE_VIEW.cy - tex.y * GLOBE_VIEW.r, }; const sx = (hp.x / GLOBE_VIEW.imgW) * (layer.w - 1); const sy = (hp.y / GLOBE_VIEW.imgH) * (layer.h - 1); return sampleImageDataBilinear(layer.data, layer.w, layer.h, sx, sy); } function texLonLat(tex) { return { lon: Math.atan2(tex.x, tex.z), lat: Math.asin(Math.max(-1, Math.min(1, tex.y))), }; } function wrapLon(lon) { let l = lon; while (l > Math.PI) { l -= Math.PI * 2; } while (l < -Math.PI) { l += Math.PI * 2; } return l; } /** Inverse Mercator row — matches extract_red_polygons.py mercator_y_to_lat. */ function latToMercatorRow(lat, h) { const clamped = Math.max(-1.52, Math.min(1.52, lat)); const t = 0.5 + Math.asinh(Math.tan(clamped)) / (2 * Math.PI); return Math.max(0, Math.min(h - 1, (1 - t) * (h - 1))); } function sampleMercFlat(mercFlat, lon, lat) { const u = ((wrapLon(lon) + Math.PI) / (2 * Math.PI)) * (mercFlat.w - 1); const v = latToMercatorRow(lat, mercFlat.h); return sampleImageDataBilinearWrapU(mercFlat.data, mercFlat.w, mercFlat.h, u, v); } /** Inverse hub-disk → flat plate (same affine as globe SVG), X wraps 360°. */ function sampleFlatPlateFromHub(mercFlat, hpX, hpY) { const fx = (hpX - CONTINENT_INNER_TX) / CONTINENT_INNER_SX; const fy = (hpY - CONTINENT_INNER_TY) / CONTINENT_INNER_SY; if (fy < 0 || fy > CONTINENT_FLAT_H - 1) { return null; } const sx = (fx / CONTINENT_FLAT_W) * mercFlat.w; const sy = (fy / CONTINENT_FLAT_H) * mercFlat.h; return sampleImageDataBilinearWrapU(mercFlat.data, mercFlat.w, mercFlat.h, sx, sy); } /** Sphere tex → mercator-flat plate (lon center + inverse Mercator Y). */ function sampleMercFlatFromTex(mercFlat, tex, landZoom) { const ll = texLonLat(tex); const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex); return sampleMercFlat(mercFlat, mapped.lon, mapped.lat); } function isLandPixel(r, g, b, a) { return a > 10 && r > 100 && g < 140 && b < 140; } /** hub-logo-continents-mercator-{globe,flat}.svg grey-blue fills (not red JPG extract). */ function isHubContinentPixel(r, g, b, a) { if (a < 8) { return false; } if (r > 168 && g > 198 && b > 218) { return false; } if (r < 48 && g < 58 && b < 82) { return false; } const lum = r * 0.299 + g * 0.587 + b * 0.114; if (lum < 92 || lum > 238) { return false; } return b >= 100 && g >= 95 && r >= 68; } function hubContinentRgba(sample) { return [sample[0], sample[1], sample[2], 255]; } /** Fill vs stroke from hub-logo-continents-mercator-flat.svg raster. */ function classifyHubContinentPixel(r, g, b, a) { if (a < 8) { return null; } if (r > 168 && g > 198 && b > 218) { return null; } const lum = r * 0.299 + g * 0.587 + b * 0.114; if (lum < 92 || lum > 238) { return null; } if (!(b >= 100 && g >= 95 && r >= 68)) { return null; } if (lum >= 186 || (g > 198 && b > 212)) { return 'border'; } return 'fill'; } /** Solid fill from rasterized globe SVG — skip gradient fringe / drop-shadow. */ function isSolidContinentPixel(r, g, b, a) { if (a < 72) { return false; } if (r > 168 && g > 198 && b > 218) { return false; } if (r < 58 && g < 68 && b < 95) { return false; } const lum = r * 0.299 + g * 0.587 + b * 0.114; if (lum < 102 || lum > 218) { return false; } return b >= 105 && g >= 100 && r >= 72; } /** Light hub grid strokes baked into hub-logo-fragment-globe.svg (vector grid drawn separately). */ function isGridPixel(r, g, b, a) { if (a < 12) { return false; } const lum = r * 0.299 + g * 0.587 + b * 0.114; if (lum < 125) { return false; } if (g < 155 || b < 155) { return false; } return Math.max(r, g, b) - Math.min(r, g, b) < 85; } const OCEAN_SOLID = [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; function sampleOceanSolid() { return OCEAN_SOLID; } function sampleOceanClean(globe, tex) { if (tex.z <= FRONT_Z) { return OCEAN_SOLID; } const s = sampleHub(globe, tex); if (s[3] > 8 && !isGridPixel(s[0], s[1], s[2], s[3])) { return [s[0], s[1], s[2], 255]; } return OCEAN_SOLID; } function sampleLandRgbaLegacy(continents, mercFlat, tex) { if (tex.z > FRONT_Z) { const ortho = sampleHub(continents, tex); if (ortho[3] > 6) { return ortho; } return null; } if (tex.z > -FRONT_Z) { return null; } const ll = texLonLat(tex); const flat = sampleMercFlat(mercFlat, ll.lon, ll.lat); if (isLandPixel(flat[0], flat[1], flat[2], flat[3])) { return [flat[0], flat[1], flat[2], Math.min(255, flat[3])]; } return null; } /** globe3d>=2: mercator-flat on entire visible hemisphere (z>0). */ function sampleLandRgbaMercator(mercFlat, tex) { if (tex.z <= LIMB_Z) { return null; } const ll = texLonLat(tex); const flat = sampleMercFlat(mercFlat, ll.lon, ll.lat); if (isLandPixel(flat[0], flat[1], flat[2], flat[3])) { return [flat[0], flat[1], flat[2], Math.min(255, flat[3])]; } return null; } /** globe3d=11 — geographic mercator wrap; semi-transparent fill + contrast borders. */ /** Precomputed mercator-flat land mask (0=ocean, 1=fill, 2=border, 3=coast). */ function buildMercLandCache(mercFlat) { const w = mercFlat.w; const h = mercFlat.h; const n = w * h; const kind = new Uint8Array(n); const rgb = new Uint8Array(n * 3); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; const o = i * 4; const r = mercFlat.data[o]; const g = mercFlat.data[o + 1]; const b = mercFlat.data[o + 2]; const a = mercFlat.data[o + 3]; const cls = classifyHubContinentPixel(r, g, b, a); if (!cls) { continue; } kind[i] = cls === 'border' ? 2 : 1; rgb[i * 3] = r; rgb[i * 3 + 1] = g; rgb[i * 3 + 2] = b; } } for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; if (kind[i] !== 1) { continue; } if ( (x > 0 && kind[i - 1] === 0) || (x < w - 1 && kind[i + 1] === 0) || (y > 0 && kind[i - w] === 0) || (y < h - 1 && kind[i + w] === 0) ) { kind[i] = 3; } } } return { w: w, h: h, kind: kind, rgb: rgb }; } function sampleLandFromCache(cache, tex, landZoom, continentStyle, viewZ) { const cs = continentStyle || resolveContinentStyle(null); if (viewZ <= LIMB_Z) { return null; } const ll = texLonLat(tex); const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex); const u = ((wrapLon(mapped.lon) + Math.PI) / (2 * Math.PI)) * (cache.w - 1); const v = latToMercatorRow(mapped.lat, cache.h); const x = Math.max(0, Math.min(cache.w - 1.001, u)); const y = Math.max(0, Math.min(cache.h - 1.001, v)); const x0 = Math.floor(x); const y0 = Math.floor(y); const x1 = Math.min(x0 + 1, cache.w - 1); const y1 = Math.min(y0 + 1, cache.h - 1); const fx = x - x0; const fy = y - y0; const i00 = y0 * cache.w + x0; const i10 = y0 * cache.w + x1; const i01 = y1 * cache.w + x0; const i11 = y1 * cache.w + x1; const k = Math.max(cache.kind[i00], cache.kind[i10], cache.kind[i01], cache.kind[i11]); if (k === 0) { return null; } const w00 = (1 - fx) * (1 - fy); const w10 = fx * (1 - fy); const w01 = (1 - fx) * fy; const w11 = fx * fy; const r = Math.round( w00 * cache.rgb[i00 * 3] + w10 * cache.rgb[i10 * 3] + w01 * cache.rgb[i01 * 3] + w11 * cache.rgb[i11 * 3] ); const g = Math.round( w00 * cache.rgb[i00 * 3 + 1] + w10 * cache.rgb[i10 * 3 + 1] + w01 * cache.rgb[i01 * 3 + 1] + w11 * cache.rgb[i11 * 3 + 1] ); const b = Math.round( w00 * cache.rgb[i00 * 3 + 2] + w10 * cache.rgb[i10 * 3 + 2] + w01 * cache.rgb[i01 * 3 + 2] + w11 * cache.rgb[i11 * 3 + 2] ); if (k === 2 || k === 3) { return [ Math.min(255, r + cs.borderBoostR), Math.min(255, g + cs.borderBoostG), Math.min(255, b + cs.borderBoostB), cs.borderAlpha, ]; } return [r, g, b, cs.fillAlpha]; } /** Hot path — land disk without per-pixel fn dispatch (globe3d=11). */ function fillLandDiskFromCache(ctx, cx, cy, r, rotY, cache, landZoom, continentStyle) { const cs = continentStyle || resolveContinentStyle(null); const r2 = r * r; const x0 = Math.max(0, Math.floor(cx - r)); const y0 = Math.max(0, Math.floor(cy - r)); const x1 = Math.min(ctx.canvas.width - 1, Math.ceil(cx + r)); const y1 = Math.min(ctx.canvas.height - 1, Math.ceil(cy + r)); const w = x1 - x0 + 1; const h = y1 - y0 + 1; if (w <= 0 || h <= 0) { return null; } const out = ctx.createImageData(w, h); const px = out.data; const cw = cache.w; const ch = cache.h; const cwm1 = cw - 1.001; const chm1 = ch - 1.001; let p = 0; for (let dy = y0; dy <= y1; dy++) { const oy = dy - cy; const oyR = oy / r; const oyR2 = oyR * oyR; for (let dx = x0; dx <= x1; dx++) { const ox = dx - cx; if (ox * ox + oy * oy > r2) { px[p++] = 0; px[p++] = 0; px[p++] = 0; px[p++] = 0; continue; } const nx = ox / r; const nz = Math.sqrt(Math.max(0, 1 - nx * nx - oyR2)); if (nz <= LIMB_Z) { px[p++] = 0; px[p++] = 0; px[p++] = 0; px[p++] = 0; continue; } const tex = bodyToTex(inverseRotateY(nx, oyR, nz, rotY)); const ll = texLonLat(tex); const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex); const u = ((wrapLon(mapped.lon) + Math.PI) / (2 * Math.PI)) * (cw - 1); const v = latToMercatorRow(mapped.lat, ch); const xf = Math.max(0, Math.min(cwm1, u)); const yf = Math.max(0, Math.min(chm1, v)); const x0i = xf | 0; const y0i = yf | 0; const x1i = x0i + 1 < cw ? x0i + 1 : x0i; const y1i = y0i + 1 < ch ? y0i + 1 : y0i; const fx = xf - x0i; const fy = yf - y0i; const i00 = y0i * cw + x0i; const i10 = y0i * cw + x1i; const i01 = y1i * cw + x0i; const i11 = y1i * cw + x1i; const k = Math.max(cache.kind[i00], cache.kind[i10], cache.kind[i01], cache.kind[i11]); if (k === 0) { px[p++] = 0; px[p++] = 0; px[p++] = 0; px[p++] = 0; continue; } const w00 = (1 - fx) * (1 - fy); const w10 = fx * (1 - fy); const w01 = (1 - fx) * fy; const w11 = fx * fy; const rgb = cache.rgb; let rv = Math.round( w00 * rgb[i00 * 3] + w10 * rgb[i10 * 3] + w01 * rgb[i01 * 3] + w11 * rgb[i11 * 3] ); let gv = Math.round( w00 * rgb[i00 * 3 + 1] + w10 * rgb[i10 * 3 + 1] + w01 * rgb[i01 * 3 + 1] + w11 * rgb[i11 * 3 + 1] ); let bv = Math.round( w00 * rgb[i00 * 3 + 2] + w10 * rgb[i10 * 3 + 2] + w01 * rgb[i01 * 3 + 2] + w11 * rgb[i11 * 3 + 2] ); if (k === 2 || k === 3) { rv = Math.min(255, rv + cs.borderBoostR); gv = Math.min(255, gv + cs.borderBoostG); bv = Math.min(255, bv + cs.borderBoostB); px[p++] = rv; px[p++] = gv; px[p++] = bv; px[p++] = cs.borderAlpha; } else { px[p++] = rv; px[p++] = gv; px[p++] = bv; px[p++] = cs.fillAlpha; } } } return { out: out, x0: x0, y0: y0, stride: 1 }; } function fillDiskRegion(ctx, cx, cy, r, rotY, fn, step) { const stride = step && step > 1 ? step : 1; const r2 = r * r; const x0 = Math.max(0, Math.floor(cx - r)); const y0 = Math.max(0, Math.floor(cy - r)); const x1 = Math.min(ctx.canvas.width - 1, Math.ceil(cx + r)); const y1 = Math.min(ctx.canvas.height - 1, Math.ceil(cy + r)); const w = Math.ceil((x1 - x0 + 1) / stride); const h = Math.ceil((y1 - y0 + 1) / stride); if (w <= 0 || h <= 0) { return null; } const out = ctx.createImageData(w, h); const px = out.data; let p = 0; for (let j = 0; j < h; j++) { const dy = y0 + j * stride; for (let i = 0; i < w; i++) { const dx = x0 + i * stride; const ox = dx - cx; const oy = dy - cy; if (ox * ox + oy * oy > r2) { px[p++] = 0; px[p++] = 0; px[p++] = 0; px[p++] = 0; continue; } const nx = ox / r; const ny = oy / r; const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny)); const tex = bodyToTex(inverseRotateY(nx, ny, nz, rotY)); const rgba = fn(tex, nz); px[p++] = rgba[0]; px[p++] = rgba[1]; px[p++] = rgba[2]; px[p++] = rgba[3] !== undefined ? rgba[3] : 255; } } return { out: out, x0: x0, y0: y0, stride: stride, w: w, h: h }; } function drawSolidOceanDisk(ctx, cx, cy, r) { ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = 'rgb(' + OCEAN_FALLBACK.r + ',' + OCEAN_FALLBACK.g + ',' + OCEAN_FALLBACK.b + ')'; ctx.fill(); ctx.restore(); } function blitLayer(ctx, cx, cy, r, layer, scratch) { if (!layer) { return scratch; } const { out, x0, y0, stride } = layer; const sw = out.width; const sh = out.height; const dw = sw * stride; const dh = sh * stride; if (!scratch || scratch.width < dw || scratch.height < dh) { scratch = document.createElement('canvas'); scratch.width = dw; scratch.height = dh; } const sctx = scratch.getContext('2d'); if (stride === 1) { sctx.putImageData(out, 0, 0); } else { const small = scratch._smallCanvas || document.createElement('canvas'); if (small.width !== sw || small.height !== sh) { small.width = sw; small.height = sh; } scratch._smallCanvas = small; small.getContext('2d').putImageData(out, 0, 0); sctx.clearRect(0, 0, dw, dh); sctx.imageSmoothingEnabled = true; sctx.imageSmoothingQuality = 'high'; sctx.drawImage(small, 0, 0, sw, sh, 0, 0, dw, dh); } ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(scratch, x0, y0, dw, dh); ctx.restore(); return scratch; } function drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale) { const drawW = GLOBE_VIEW.imgW * layoutScale; const drawH = GLOBE_VIEW.imgH * layoutScale; const dx = cx - GLOBE_VIEW.cx * layoutScale; const dy = cy - GLOBE_VIEW.cy * layoutScale; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, rim, 0, Math.PI * 2); ctx.arc(cx, cy, r, 0, Math.PI * 2, true); ctx.clip(); ctx.drawImage(globeImg, 0, 0, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, dx, dy, drawW, drawH); ctx.restore(); } export async function mountHubGlobe(stage, opts) { const build = opts.build || 'earth3d'; const reducedMotion = !!opts.reducedMotion; const renderVersion = Math.max(1, parseInt(opts.renderVersion, 10) || 1); const landZoom = resolveLandZoom(opts.landZoom); const continentStyle = resolveContinentStyle(opts.continentStyle); const sunlightStyle = resolveSunlightStyle(opts.sunlightStyle); const fogStyle = resolveFogStyle(opts.fogStyle); const spinFactor = resolveSpinFactor(opts.spinFactor); const spinTarget = AUTO_SPIN_RAD_S * spinFactor; const globePerf = resolveGlobePerf(opts.globePerf); const wantGpuLand = renderVersion >= 11 && globePerf.useGpu; const canvasList = []; let bodyCanvas = null; let landCanvas = null; let overlayCanvas = null; let canvas = null; function makeCanvas(className) { const c = document.createElement('canvas'); c.className = className; if (renderVersion >= 11 && fogStyle.intensity > 0 && fogStyle.outer > 0) { c.classList.add('hub-globe-canvas--halo'); } c.setAttribute('aria-hidden', 'true'); stage.appendChild(c); canvasList.push(c); return c; } if (wantGpuLand) { bodyCanvas = makeCanvas('hub-globe-canvas hub-globe-canvas--body'); landCanvas = makeCanvas('hub-globe-canvas hub-globe-canvas--land'); overlayCanvas = makeCanvas('hub-globe-canvas hub-globe-canvas--overlay'); canvas = overlayCanvas; } else { canvas = makeCanvas('hub-globe-canvas'); } const urls = { globe: hubAsset('assets/hub-logo-fragment-globe.svg', build), continents: hubAsset('assets/hub-logo-continents-mercator-globe.svg', build), mercatorFlat: hubAsset('assets/hub-logo-continents-mercator-flat.svg', build), }; let globeImg; let continentsImg; let mercatorFlatImg; try { [globeImg, continentsImg, mercatorFlatImg] = await Promise.all([ loadImage(urls.globe), loadImage(urls.continents), loadImage(urls.mercatorFlat), ]); } catch (e) { console.warn('HubGlobe: asset load failed', e); canvasList.forEach(function (c) { c.remove(); }); return null; } const globe = rasterizeImage(globeImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE); const continents = rasterizeImage(continentsImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE); const mercW = (mercatorFlatImg.naturalWidth || 800) * 2; const mercH = (mercatorFlatImg.naturalHeight || 519) * 2; const mercFlat = rasterizeImage(mercatorFlatImg, mercW, mercH, 1); const mercLandCache = renderVersion >= 11 ? buildMercLandCache(mercFlat) : null; let gpuRenderer = null; let useGpuLand = false; if (wantGpuLand && landCanvas && mercLandCache) { try { const gpuMod = await import(hubAsset('assets/hub-globe-gpu.js', build)); gpuRenderer = gpuMod.createGpuGlobeRenderer(landCanvas, { cache: mercLandCache, continentStyle: continentStyle, landZoom: landZoom, lonCenter: MERCATOR_LON_CENTER, }); if (gpuRenderer) { useGpuLand = true; } } catch (gpuErr) { console.warn('HubGlobe: GPU land unavailable', gpuErr); } } if (wantGpuLand && !useGpuLand) { if (bodyCanvas) { bodyCanvas.remove(); canvasList.splice(canvasList.indexOf(bodyCanvas), 1); bodyCanvas = null; } if (landCanvas) { landCanvas.remove(); canvasList.splice(canvasList.indexOf(landCanvas), 1); landCanvas = null; } if (overlayCanvas) { overlayCanvas.className = 'hub-globe-canvas'; canvas = overlayCanvas; } } let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : spinTarget; let dragging = false; let lastPointerX = 0; let lastPointerTime = 0; let raf = 0; let lastFrame = performance.now(); let lastRenderAt = 0; let landScratch = null; let baseDiskCache = null; let baseDiskCacheR = 0; let decorCache = null; let decorCacheKey = ''; let heroVisible = true; let heroIo = null; let tickActive = false; let tickTimer = 0; const ctx = canvas.getContext('2d', { willReadFrequently: renderVersion < 11 && !useGpuLand }); const bodyCtx = bodyCanvas ? bodyCanvas.getContext('2d') : null; const invalidateLayerCaches = () => { baseDiskCache = null; baseDiskCacheR = 0; decorCache = null; decorCacheKey = ''; }; const rebuildBaseDiskCache = (r) => { const size = Math.ceil(r * 2); const c = document.createElement('canvas'); c.width = size; c.height = size; const bctx = c.getContext('2d'); drawSolidOceanDisk(bctx, r, r, r); drawGlobeSunlight(bctx, r, r, r, sunlightStyle); baseDiskCache = c; baseDiskCacheR = r; }; const rebuildDecorCache = (width, height, cx, cy, r, rim, layoutScale) => { const c = document.createElement('canvas'); c.width = width; c.height = height; const dctx = c.getContext('2d'); drawOceanRim(dctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion); drawGlobeFog(dctx, cx, cy, r, fogStyle); drawGlobeBodyEdge(dctx, cx, cy, r, continentStyle, layoutScale); decorCache = c; decorCacheKey = width + 'x' + height + '@' + Math.round(cx) + ',' + Math.round(cy) + ',' + Math.round(r); }; const applyCanvasGeometry = (c) => { const rect = globeCoverRect(); const dpr = Math.min( window.devicePixelRatio || 1, renderVersion >= 11 ? globePerf.dprMax : 2 ); const padCss = renderVersion >= 11 ? fogPadCss(rect, fogStyle) : 0; const cssW = rect.width + padCss * 2; const cssH = rect.height + padCss * 2; c.style.left = rect.left - padCss + 'px'; c.style.top = rect.top - padCss + 'px'; c.style.width = cssW + 'px'; c.style.height = cssH + 'px'; c.width = Math.max(64, Math.floor(cssW * dpr)); c.height = Math.max(64, Math.floor(cssH * dpr)); return { rect, dpr, padCss }; }; const resize = () => { const layout = applyCanvasGeometry(canvas); if (bodyCanvas) { applyCanvasGeometry(bodyCanvas); } if (landCanvas) { applyCanvasGeometry(landCanvas); } invalidateLayerCaches(); return layout; }; let layout = resize(); const onWindowResize = () => { layout = resize(); }; window.addEventListener('resize', onWindowResize); const heroPage = document.getElementById('landing-page-1'); const scrollRoot = document.getElementById('landing-scroll'); if (heroPage && scrollRoot && typeof IntersectionObserver !== 'undefined') { heroIo = new IntersectionObserver( function (entries) { heroVisible = !!(entries[0] && entries[0].isIntersecting); if (heroVisible && !document.hidden) { startTick(); } else { stopTick(); } }, { root: scrollRoot, threshold: 0.08 } ); heroIo.observe(heroPage); } const onVisibility = () => { if (document.hidden || !heroVisible) { stopTick(); } else { startTick(); } }; document.addEventListener('visibilitychange', onVisibility); const spinFrameMs = 1000 / globePerf.spinFps; const render = () => { const t0 = globePerf.profile ? performance.now() : 0; const { rect, dpr, padCss } = layout; const padPx = padCss * dpr; const cx = (rect.cx - rect.left) * dpr + padPx; const cy = (rect.cy - rect.top) * dpr + padPx; const r = rect.r * dpr; const rim = rect.rim * dpr; const layoutScale = rect.scale * dpr; if (useGpuLand && bodyCtx && gpuRenderer && bodyCanvas && landCanvas) { if (!baseDiskCache || baseDiskCacheR !== r) { rebuildBaseDiskCache(r); } bodyCtx.clearRect(0, 0, bodyCanvas.width, bodyCanvas.height); bodyCtx.drawImage(baseDiskCache, cx - r, cy - r); gpuRenderer.draw(cx, cy, r, rotation, landCanvas.width, landCanvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height); } else { ctx.clearRect(0, 0, canvas.width, canvas.height); if (renderVersion >= 11) { if (!baseDiskCache || baseDiskCacheR !== r) { rebuildBaseDiskCache(r); } ctx.drawImage(baseDiskCache, cx - r, cy - r); landScratch = blitLayer( ctx, cx, cy, r, fillLandDiskFromCache(ctx, cx, cy, r, rotation, mercLandCache, landZoom, continentStyle), landScratch ); } else { blitLayer( ctx, cx, cy, r, fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { if (renderVersion >= 3) { return sampleOceanSolid(); } return sampleOceanClean(globe, tex); }) ); } if (renderVersion === 1) { blitLayer( ctx, cx, cy, r, fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { const l = sampleLandRgbaLegacy(continents, mercFlat, tex); return l || [0, 0, 0, 0]; }) ); } else if (renderVersion === 2) { blitLayer( ctx, cx, cy, r, fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { const l = sampleLandRgbaMercator(mercFlat, tex); return l || [0, 0, 0, 0]; }) ); } } drawGlobeGrid(ctx, cx, cy, r, rotation, layoutScale, renderVersion); if (renderVersion >= 11) { const dKey = canvas.width + 'x' + canvas.height + '@' + Math.round(cx) + ',' + Math.round(cy) + ',' + Math.round(r); if (!decorCache || decorCacheKey !== dKey) { rebuildDecorCache(canvas.width, canvas.height, cx, cy, r, rim, layoutScale); } ctx.drawImage(decorCache, 0, 0); } else if (!useGpuLand) { drawOceanRim(ctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(10,14,20,0.55)'; ctx.lineWidth = Math.max(2, layoutScale * 0.006); ctx.stroke(); ctx.restore(); } if (globePerf.profile) { console.log( '[globe] render', (performance.now() - t0).toFixed(2) + 'ms', useGpuLand ? 'gpu-land' : 'cpu', 'r=' + Math.round(r) ); } }; const scheduleRender = (now) => { lastRenderAt = now || performance.now(); render(); }; const onPointerDown = (ev) => { if (ev.button !== 0) return; dragging = true; lastPointerX = ev.clientX; lastPointerTime = performance.now(); canvas.setPointerCapture(ev.pointerId); canvas.classList.add('is-dragging'); ev.preventDefault(); }; const onPointerMove = (ev) => { if (!dragging) return; const now = performance.now(); const dx = ev.clientX - lastPointerX; rotation += dx * DRAG_ROTATION_PER_PX; if (now > lastPointerTime) { const inst = (dx * DRAG_ROTATION_PER_PX) / ((now - lastPointerTime) / 1000); angularVelocity = Math.max(-2.5, Math.min(2.5, inst)); } lastPointerX = ev.clientX; lastPointerTime = now; scheduleRender(now); ev.preventDefault(); }; const endDrag = (ev) => { if (!dragging) return; dragging = false; canvas.classList.remove('is-dragging'); try { canvas.releasePointerCapture(ev.pointerId); } catch (ignored) {} }; canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', endDrag); canvas.addEventListener('pointercancel', endDrag); const tick = (now) => { tickActive = false; const dt = Math.min(0.05, (now - lastFrame) / 1000); lastFrame = now; if (!dragging) { if (!reducedMotion) { const target = spinTarget; angularVelocity += (target - angularVelocity) * 1.2 * dt; if (Math.abs(angularVelocity - target) < 0.004) angularVelocity = target; } else { angularVelocity *= Math.exp(-4 * dt); } rotation += angularVelocity * dt; } if (document.hidden || !heroVisible) { return; } const minInterval = dragging ? 0 : spinFrameMs; if (now - lastRenderAt < minInterval) { startTick(); return; } scheduleRender(now); startTick(); }; function startTick() { if (tickActive) { return; } tickActive = true; if (!dragging && spinFrameMs >= 34) { tickTimer = window.setTimeout(function () { tickTimer = 0; tick(performance.now()); }, spinFrameMs); } else { raf = requestAnimationFrame(tick); } } function stopTick() { if (raf) { cancelAnimationFrame(raf); raf = 0; } if (tickTimer) { clearTimeout(tickTimer); tickTimer = 0; } tickActive = false; } scheduleRender(performance.now()); startTick(); stage.dataset.globe3d = String(renderVersion); if (spinFactor !== GLOBE_SPIN_FACTOR_DEFAULT) { stage.dataset.globeAx = String(spinFactor); } if (renderVersion >= 11) { stage.dataset.globeLatf = String(landZoom.latf); if (useGpuLand) { stage.dataset.globeGpu = '1'; } stage.dataset.globeLngf = String(landZoom.lngf); stage.dataset.globeZy = String(landZoom.zy); stage.dataset.globeZx = String(landZoom.zx); stage.dataset.globePp = String(landZoom.pp); stage.dataset.globePe = String(landZoom.pe); stage.dataset.globeCfa = String(continentStyle.fillAlpha); stage.dataset.globeCba = String(continentStyle.borderAlpha); stage.dataset.globeCbr = String(continentStyle.borderBoostR); stage.dataset.globeCbg = String(continentStyle.borderBoostG); stage.dataset.globeCbb = String(continentStyle.borderBoostB); stage.dataset.globeCeps = String(continentStyle.coastEps); stage.dataset.globeSlx = String(sunlightStyle.lx); stage.dataset.globeSly = String(sunlightStyle.ly); if (sunlightStyle.dir != null) { stage.dataset.globeSld = String(sunlightStyle.dir); stage.dataset.globeSll = String(sunlightStyle.span); } else { stage.dataset.globeSlx2 = String(sunlightStyle.lx2); stage.dataset.globeSly2 = String(sunlightStyle.ly2); } stage.dataset.globeSli = String(sunlightStyle.intensity); stage.dataset.globeSlhi = String(sunlightStyle.highlight); stage.dataset.globeSlsh = String(sunlightStyle.shadow); stage.dataset.globeFogi = String(fogStyle.intensity); stage.dataset.globeFogr = String(fogStyle.outer); stage.dataset.globeFogin = String(fogStyle.inner); if (fogStyle.intensity > 0 && fogStyle.outer > 0) { stage.dataset.globeFogPad = String(fogPadCss(globeCoverRect(), fogStyle)); } } stage.dataset.globeAxis = renderVersion === 11 ? 'ew-sphere-ellipse-land-lit-world-mer' : renderVersion === 10 ? 'ew-sphere-ellipse' : renderVersion === 9 ? 'ew-ns-sphere-v4' : renderVersion >= 8 ? 'ew-hub-ellipse-rot' : renderVersion >= 7 ? 'ew-sphere-stroke' : renderVersion >= 6 ? 'ew-sphere-band' : renderVersion >= 5 ? 'ew-hub-ellipse' : renderVersion >= 3 ? 'ew-sphere' : renderVersion >= 2 ? 'ew-ns-sphere' : 'ew-ns-grid'; return { destroy() { stopTick(); window.removeEventListener('resize', onWindowResize); document.removeEventListener('visibilitychange', onVisibility); if (heroIo) { heroIo.disconnect(); heroIo = null; } if (gpuRenderer) { gpuRenderer.destroy(); gpuRenderer = null; } canvasList.forEach(function (c) { c.remove(); }); canvasList.length = 0; delete stage.dataset.globe3d; delete stage.dataset.globeAxis; delete stage.dataset.globeAx; delete stage.dataset.globeLatf; delete stage.dataset.globeLngf; delete stage.dataset.globeZy; delete stage.dataset.globeZx; delete stage.dataset.globePp; delete stage.dataset.globePe; delete stage.dataset.globeCfa; delete stage.dataset.globeCba; delete stage.dataset.globeCbr; delete stage.dataset.globeCbg; delete stage.dataset.globeCbb; delete stage.dataset.globeCeps; delete stage.dataset.globeSlx; delete stage.dataset.globeSly; delete stage.dataset.globeSlx2; delete stage.dataset.globeSly2; delete stage.dataset.globeSld; delete stage.dataset.globeSll; delete stage.dataset.globeSli; delete stage.dataset.globeSlhi; delete stage.dataset.globeSlsh; delete stage.dataset.globeFogi; delete stage.dataset.globeFogr; delete stage.dataset.globeFogin; delete stage.dataset.globeGpu; }, }; }