/** * 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 + ortho hub land + affine flat-plate wrap fallback. */ export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 }; const AUTO_SPIN_RAD_S = 0.035; const DRAG_ROTATION_PER_PX = 0.004; const OCEAN_FALLBACK = { r: 22, g: 38, b: 60 }; 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; 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 meridians (line + quadratic beziers). */ 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) }, ]; 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 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), }; } 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 rgb = par.bright ? '219,231,246' : '231,236,243'; const alpha = par.bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t; const layoutScale = r / GLOBE_VIEW.r; const lineW = Math.max(par.bright ? 2.5 : 2, layoutScale * 0.0075) * (0.55 + 0.45 * 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(' + rgb + ',' + alpha.toFixed(3) + ')'; ctx.lineWidth = 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=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 || renderVersion === 11) { drawSphereGridScreenStrip(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 }); } function drawOceanRim(ctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion) { 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 offset + inverse Mercator Y). */ function sampleMercFlatFromTex(mercFlat, tex) { const ll = texLonLat(tex); const lon = wrapLon(ll.lon + (12 * Math.PI) / 180); return sampleMercFlat(mercFlat, lon, ll.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]; } /** 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 — ortho hub (reference N–S) + wrapped flat-plate fallback. */ function sampleLandVisibleHemisphere(continents, mercFlat, tex) { if (tex.z <= LIMB_Z) { return null; } const ortho = sampleHub(continents, tex); if (isSolidContinentPixel(ortho[0], ortho[1], ortho[2], ortho[3])) { return hubContinentRgba(ortho); } const hpX = GLOBE_VIEW.cx + tex.x * GLOBE_VIEW.r; const hpY = GLOBE_VIEW.cy - tex.y * GLOBE_VIEW.r; const flat = sampleFlatPlateFromHub(mercFlat, hpX, hpY); if (flat && isHubContinentPixel(flat[0], flat[1], flat[2], flat[3])) { return hubContinentRgba(flat); } return null; } function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { 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; let p = 0; for (let dy = y0; dy <= y1; dy++) { for (let dx = x0; dx <= x1; dx++) { 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); px[p++] = rgba[0]; px[p++] = rgba[1]; px[p++] = rgba[2]; px[p++] = rgba[3] !== undefined ? rgba[3] : 255; } } return { out, x0, y0 }; } function blitLayer(ctx, cx, cy, r, layer) { if (!layer) { return; } const { out, x0, y0 } = layer; const w = out.width; const h = out.height; const src = out.data; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); const existing = ctx.getImageData(x0, y0, w, h); const dst = existing.data; for (let i = 0; i < src.length; i += 4) { const sa = src[i + 3]; if (sa === 0) { continue; } if (sa === 255) { dst[i] = src[i]; dst[i + 1] = src[i + 1]; dst[i + 2] = src[i + 2]; dst[i + 3] = 255; continue; } const a = sa / 255; dst[i] = Math.round(src[i] * a + dst[i] * (1 - a)); dst[i + 1] = Math.round(src[i + 1] * a + dst[i + 1] * (1 - a)); dst[i + 2] = Math.round(src[i + 2] * a + dst[i + 2] * (1 - a)); dst[i + 3] = 255; } ctx.putImageData(existing, x0, y0); ctx.restore(); } 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 canvas = document.createElement('canvas'); canvas.className = 'hub-globe-canvas'; canvas.setAttribute('aria-hidden', 'true'); stage.appendChild(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); canvas.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); let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S; let dragging = false; let lastPointerX = 0; let lastPointerTime = 0; let raf = 0; let lastFrame = performance.now(); const ctx = canvas.getContext('2d', { willReadFrequently: true }); const resize = () => { const rect = globeCoverRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); const px = Math.max(64, Math.floor(rect.width * dpr)); canvas.style.left = rect.left + 'px'; canvas.style.top = rect.top + 'px'; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; canvas.width = px; canvas.height = px; return { rect, dpr, px }; }; let layout = resize(); window.addEventListener('resize', () => { layout = resize(); }); const render = () => { const { rect, dpr, px } = layout; const cx = (rect.cx - rect.left) * dpr; const cy = (rect.cy - rect.top) * dpr; const r = rect.r * dpr; const rim = rect.rim * dpr; const layoutScale = rect.scale * dpr; ctx.clearRect(0, 0, px, px); 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]; }) ); } else if (renderVersion >= 11) { blitLayer( ctx, cx, cy, r, fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { const l = sampleLandVisibleHemisphere(continents, mercFlat, tex); return l || [0, 0, 0, 0]; }) ); } drawGlobeGrid(ctx, cx, cy, r, rotation, layoutScale, renderVersion); 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(); }; 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; 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) => { raf = requestAnimationFrame(tick); const dt = Math.min(0.05, (now - lastFrame) / 1000); lastFrame = now; if (!dragging) { if (!reducedMotion) { const target = AUTO_SPIN_RAD_S; angularVelocity += (target - angularVelocity) * 1.2 * dt; if (Math.abs(angularVelocity - target) < 0.004) angularVelocity = target; } else { angularVelocity *= Math.exp(-4 * dt); } rotation += angularVelocity * dt; } render(); }; raf = requestAnimationFrame(tick); stage.dataset.globe3d = String(renderVersion); stage.dataset.globeAxis = renderVersion === 11 ? 'ew-sphere-ellipse-land' : 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() { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); canvas.remove(); delete stage.dataset.globe3d; delete stage.dataset.globeAxis; }, }; }