/** * Hub landing globe — orthographic Canvas2D (globe3d=1). * Grid: hub SVG ellipses (E–W) + meridian paths (N–S) traced on sphere, then Y-rotated. * Land: ortho hub disk on front face; mercator-flat only on back wrap (avoids front squeeze). */ 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 RASTER_SCALE = 2; const AXIS_STEPS = 160; /** 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 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; const body = hubPixelToBody( ellipse.cx + ellipse.rx * Math.cos(a), ellipse.cy + ellipse.ry * Math.sin(a) ); if (body) { out.push(body); } } return out; } function parallelStyle(bright, scale, z) { const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (0.95 - FRONT_Z))); 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) { const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (0.95 - FRONT_Z))); 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) { let run = []; 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++) { const view = bodyToView(bodies[i], rotY); if (view.z <= FRONT_Z) { flush(); continue; } run.push({ sx: cx + view.x * r, sy: cy - view.y * r, z: view.z, }); } flush(); } /** Hub SVG grid: E–W ellipses + N–S meridian paths (matches globe3d=0 artwork geometry). */ function drawHubGrid(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); }); }); 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 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 sampleMercFlat(mercFlat, lon, lat) { const u = ((lon + Math.PI) / (2 * Math.PI)) * (mercFlat.w - 1); const v = (0.5 - lat / Math.PI) * (mercFlat.h - 1); return sampleImageDataBilinear(mercFlat.data, mercFlat.w, mercFlat.h, u, v); } function isLandPixel(r, g, b, a) { return a > 10 && r > 100 && g < 140 && b < 140; } /** 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; } function sampleOceanRgba(globe, tex) { if (tex.z <= FRONT_Z) { return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; } 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_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; } function sampleLandRgba(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; } 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 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) { return sampleOceanRgba(globe, tex); }) ); blitLayer( ctx, cx, cy, r, fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { const l = sampleLandRgba(continents, mercFlat, tex); return l || [0, 0, 0, 0]; }) ); drawHubGrid(ctx, cx, cy, r, rotation, layoutScale); drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale); 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 = 'active'; stage.dataset.globeAxis = 'ew-ns-grid'; return { destroy() { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); canvas.remove(); delete stage.dataset.globe3d; delete stage.dataset.globeAxis; }, }; }