diff --git a/examples/app_hub/README.md b/examples/app_hub/README.md
index f342629..d7415e9 100644
--- a/examples/app_hub/README.md
+++ b/examples/app_hub/README.md
@@ -13,6 +13,10 @@ Review one layer: `?logoPart=space|globe|full`
Compare Earth grids: `?logoPart=globe&grid=3` (default) or `?logoPart=full&grid=4`
+**3D Earth (feature/3d_earth):** Canvas orthographic globe baked from the same SVG layers as the static hub (`globe` + `continents-mercator-globe` + `gridOverlay`). Idle spin is slow (~0.035 rad/s); drag horizontally to rotate. Disable with `?globe3d=0`. Deploy `assets/hub-globe.js` with `index.html`.
+
+**3D globe background** (default): WebGL sphere with Mercator continents, meridian/equator grid, slow Y-axis spin; drag horizontally to spin (speed follows pointer). Disable: `?globe3d=0` (flat SVG layers). Default yaw: `0.42` rad (`?globeYawDeg=24` to override). Requires deploying `assets/hub-globe.js` + `hub-logo-layers.css` + updated `index.html` (see `DEPLOY.md`).
+
**BE only.** FE `apps.f0xx.org` sends `location /` → `http://artc0.intra.raptor.org:80` (not :8089).
Deploy copies/symlink under `.../androidcast_project/` on the VM. Background watermark is **3% opacity** (`hub.css`).
diff --git a/examples/app_hub/assets/hub-globe.js b/examples/app_hub/assets/hub-globe.js
new file mode 100644
index 0000000..bbc6a97
--- /dev/null
+++ b/examples/app_hub/assets/hub-globe.js
@@ -0,0 +1,404 @@
+/**
+ * Hub landing globe — orthographic Canvas2D (matches static SVG stack at rotation 0).
+ * Bakes globe + continents-mercator-globe + grid overlay into an equirectangular map,
+ * then scrolls it for Y-axis rotation (same visual language as the flat layers).
+ */
+export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 };
+
+/** Idle spin: ~4× slower than the first Three.js attempt (0.14 → 0.035 rad/s). */
+const AUTO_SPIN_RAD_S = 0.035;
+const DRAG_ROTATION_PER_PX = 0.004;
+const EQ_WIDTH = 1024;
+const EQ_HEIGHT = 512;
+
+const OCEAN_FALLBACK = { r: 22, g: 38, b: 60 };
+
+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 compositeStaticLayers(globeImg, continentsImg, gridImg) {
+ const c = document.createElement('canvas');
+ c.width = GLOBE_VIEW.imgW;
+ c.height = GLOBE_VIEW.imgH;
+ const ctx = c.getContext('2d', { willReadFrequently: true });
+ ctx.drawImage(globeImg, 0, 0, c.width, c.height);
+ ctx.drawImage(continentsImg, 0, 0, c.width, c.height);
+ ctx.drawImage(gridImg, 0, 0, c.width, c.height);
+ return { canvas: c, ctx, data: ctx.getImageData(0, 0, c.width, c.height) };
+}
+
+function sampleComposite(data, sx, sy) {
+ const w = data.width;
+ const h = data.height;
+ const x = Math.max(0, Math.min(w - 1, Math.round(sx)));
+ const y = Math.max(0, Math.min(h - 1, Math.round(sy)));
+ const i = (y * w + x) * 4;
+ return [data.data[i], data.data[i + 1], data.data[i + 2], data.data[i + 3]];
+}
+
+function sampleMercatorFlat(ctx, lon, lat) {
+ const w = ctx.canvas.width;
+ const h = ctx.canvas.height;
+ const u = (lon + Math.PI) / (2 * Math.PI);
+ const v = 0.5 - lat / Math.PI;
+ const x = Math.max(0, Math.min(w - 1, Math.round(u * (w - 1))));
+ const y = Math.max(0, Math.min(h - 1, Math.round(v * (h - 1))));
+ const d = ctx.getImageData(x, y, 1, 1).data;
+ return [d[0], d[1], d[2], d[3]];
+}
+
+/**
+ * Build equirectangular texture from the static orthographic hub artwork.
+ */
+function bakeEquirectangular(compositeData, mercatorFlatImg) {
+ const merc = document.createElement('canvas');
+ merc.width = mercatorFlatImg.naturalWidth || 800;
+ merc.height = mercatorFlatImg.naturalHeight || 519;
+ const mercCtx = merc.getContext('2d', { willReadFrequently: true });
+ mercCtx.drawImage(mercatorFlatImg, 0, 0);
+
+ const eq = document.createElement('canvas');
+ eq.width = EQ_WIDTH;
+ eq.height = EQ_HEIGHT;
+ const out = eq.getContext('2d').createImageData(EQ_WIDTH, EQ_HEIGHT);
+ const { cx, cy, r } = GLOBE_VIEW;
+
+ for (let j = 0; j < EQ_HEIGHT; j++) {
+ for (let i = 0; i < EQ_WIDTH; i++) {
+ const lon = (i / EQ_WIDTH) * 2 * Math.PI - Math.PI;
+ const lat = Math.PI * 0.5 - (j / EQ_HEIGHT) * Math.PI;
+ const cosLat = Math.cos(lat);
+ const x3 = cosLat * Math.sin(lon);
+ const y3 = Math.sin(lat);
+ const z3 = cosLat * Math.cos(lon);
+
+ let rgba;
+ if (z3 > 0.02) {
+ const sx = cx + x3 * r;
+ const sy = cy - y3 * r;
+ rgba = sampleComposite(compositeData, sx, sy);
+ if (rgba[3] < 8) {
+ rgba = sampleMercatorFlat(mercCtx, lon, lat);
+ }
+ } else {
+ rgba = sampleMercatorFlat(mercCtx, lon, lat);
+ if (rgba[3] < 16) {
+ rgba = [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255];
+ }
+ }
+
+ const p = (j * EQ_WIDTH + i) * 4;
+ out.data[p] = rgba[0];
+ out.data[p + 1] = rgba[1];
+ out.data[p + 2] = rgba[2];
+ out.data[p + 3] = 255;
+ }
+ }
+
+ eq.getContext('2d').putImageData(out, 0, 0);
+ return eq;
+}
+
+function drawEquirectSlice(ctx, eqCanvas, rotation, dx, dy, diam) {
+ const w = eqCanvas.width;
+ const h = eqCanvas.height;
+ const offsetPx = ((rotation / (2 * Math.PI)) % 1) * w;
+ const ox = ((offsetPx % w) + w) % w;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(dx, dy, diam * 0.5, 0, Math.PI * 2);
+ ctx.clip();
+ ctx.drawImage(eqCanvas, -ox, 0, w, h, dx - diam * 0.5, dy - diam * 0.5, diam, diam);
+ ctx.drawImage(eqCanvas, w - ox, 0, w, h, dx - diam * 0.5 + diam, dy - diam * 0.5, diam, diam);
+ ctx.restore();
+}
+
+function strokeMeridian(ctx, cx, cy, r, lonRad, rotY, strokeStyle, lineWidth) {
+ const pts = [];
+ for (let i = 0; i <= 64; i++) {
+ const lat = -Math.PI / 2 + (i / 64) * Math.PI;
+ const cosLat = Math.cos(lat);
+ let x = cosLat * Math.sin(lonRad);
+ let y = Math.sin(lat);
+ let z = cosLat * Math.cos(lonRad);
+ const x2 = x * Math.cos(rotY) + z * Math.sin(rotY);
+ const z2 = -x * Math.sin(rotY) + z * Math.cos(rotY);
+ if (z2 > 0.04) {
+ pts.push({ x: cx + x2 * r, y: cy - y * r });
+ }
+ }
+ if (pts.length < 2) return;
+ ctx.beginPath();
+ ctx.moveTo(pts[0].x, pts[0].y);
+ for (let k = 1; k < pts.length; k++) ctx.lineTo(pts[k].x, pts[k].y);
+ ctx.strokeStyle = strokeStyle;
+ ctx.lineWidth = lineWidth;
+ ctx.lineCap = 'round';
+ ctx.stroke();
+}
+
+function drawGridOverlay(ctx, rect, rotY) {
+ const cx = rect.cx;
+ const cy = rect.cy;
+ const r = rect.r;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.clip();
+
+ const equatorGrad = ctx.createLinearGradient(cx - r, cy, cx + r, cy);
+ equatorGrad.addColorStop(0, 'rgba(39,54,75,0.74)');
+ equatorGrad.addColorStop(0.5, 'rgba(219,231,246,0.88)');
+ equatorGrad.addColorStop(1, 'rgba(31,44,64,0.74)');
+
+ ctx.beginPath();
+ ctx.ellipse(cx, cy, r * 0.99, r * 0.034, 0, 0, 2 * Math.PI);
+ ctx.strokeStyle = equatorGrad;
+ ctx.lineWidth = Math.max(2, rect.scale * 0.007);
+ ctx.stroke();
+
+ const poleRy = r * 0.12;
+ ctx.beginPath();
+ ctx.ellipse(cx, cy - r * 0.52, r * 0.82, poleRy, 0, 0, 2 * Math.PI);
+ ctx.strokeStyle = 'rgba(231,236,243,0.72)';
+ ctx.lineWidth = Math.max(1.5, rect.scale * 0.0055);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.ellipse(cx, cy + r * 0.52, r * 0.82, poleRy, 0, 0, 2 * Math.PI);
+ ctx.stroke();
+
+ const meridianStroke = 'rgba(238,245,255,0.92)';
+ const primeStroke = 'rgba(238,245,255,0.98)';
+ const lw = Math.max(2.5, rect.scale * 0.008);
+ const lwPrime = Math.max(3, rect.scale * 0.0095);
+
+ for (let lonDeg = -150; lonDeg <= 150; lonDeg += 30) {
+ const lon = (lonDeg * Math.PI) / 180;
+ const isPrime = lonDeg === 0;
+ strokeMeridian(ctx, cx, cy, r, lon, rotY, isPrime ? primeStroke : meridianStroke, isPrime ? lwPrime : lw);
+ }
+
+ ctx.beginPath();
+ ctx.arc(cx, cy, 4, 0, Math.PI * 2);
+ ctx.fillStyle = '#5eead4';
+ ctx.fill();
+ ctx.strokeStyle = '#0a0e14';
+ ctx.lineWidth = 1;
+ ctx.stroke();
+
+ ctx.restore();
+}
+
+function drawRim(ctx, compositeCanvas, cx, cy, r, rim, scale) {
+ const drawW = GLOBE_VIEW.imgW * scale;
+ const drawH = GLOBE_VIEW.imgH * scale;
+ const dx = cx - GLOBE_VIEW.cx * scale;
+ const dy = cy - GLOBE_VIEW.cy * scale;
+
+ 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(compositeCanvas, 0, 0, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, dx, dy, drawW, drawH);
+ ctx.restore();
+}
+
+/**
+ * @param {HTMLElement} stage
+ * @param {{ build: string, reducedMotion?: boolean }} opts
+ */
+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),
+ grid: hubAsset('assets/hub-logo-fragment-globe-grid-overlay.svg', build),
+ mercatorFlat: hubAsset('assets/hub-logo-continents-mercator-flat.svg', build),
+ };
+
+ let globeImg;
+ let continentsImg;
+ let gridImg;
+ let mercatorFlatImg;
+ try {
+ [globeImg, continentsImg, gridImg, mercatorFlatImg] = await Promise.all([
+ loadImage(urls.globe),
+ loadImage(urls.continents),
+ loadImage(urls.grid),
+ loadImage(urls.mercatorFlat),
+ ]);
+ } catch (e) {
+ console.warn('HubGlobe: asset load failed', e);
+ canvas.remove();
+ return null;
+ }
+
+ const composite = compositeStaticLayers(globeImg, continentsImg, gridImg);
+ const eqCanvas = bakeEquirectangular(composite.data, mercatorFlatImg);
+
+ let rotation = 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');
+
+ 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 drawRect = {
+ cx,
+ cy,
+ r,
+ rim,
+ scale: rect.scale * dpr,
+ left: 0,
+ top: 0,
+ };
+
+ ctx.clearRect(0, 0, px, px);
+ drawEquirectSlice(ctx, eqCanvas, rotation, cx, cy, r * 2);
+ drawGridOverlay(ctx, drawRect, rotation);
+ drawRim(ctx, composite.canvas, cx, cy, r, rim, rect.scale * dpr);
+ };
+
+ 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';
+
+ return {
+ destroy() {
+ cancelAnimationFrame(raf);
+ window.removeEventListener('resize', resize);
+ canvas.remove();
+ delete stage.dataset.globe3d;
+ },
+ };
+}
diff --git a/examples/app_hub/hub-logo-layers.css b/examples/app_hub/hub-logo-layers.css
index 0906b6f..8139383 100644
--- a/examples/app_hub/hub-logo-layers.css
+++ b/examples/app_hub/hub-logo-layers.css
@@ -26,20 +26,34 @@
z-index: 1;
}
-.hub-logo-layer--globe {
- z-index: 2;
+/* Interactive globe canvas replaces globe + continents + gridOverlay when globe3d=1 */
+.hub-globe-canvas {
+ position: fixed;
+ z-index: 3;
+ pointer-events: auto;
+ cursor: grab;
+ touch-action: none;
+ border-radius: 50%;
}
-.hub-logo-layer--continents {
- z-index: 3;
+.hub-globe-canvas.is-dragging {
+ cursor: grabbing;
+}
+
+.hub-logo-layer--globe {
+ z-index: 2;
}
.hub-logo-layer--gridOverlay {
z-index: 4;
}
-.hub-logo-layer--compass {
+.hub-logo-layer--continents {
z-index: 5;
+}
+
+.hub-logo-layer--compass {
+ z-index: 6;
left: 54.7%;
top: 75.9%;
width: 11.5%;
@@ -69,3 +83,9 @@
[data-theme="light"] .hub-logo-stage {
opacity: 0.75;
}
+
+@media (prefers-reduced-motion: reduce) {
+ .hub-globe-canvas {
+ cursor: default;
+ }
+}
diff --git a/examples/app_hub/index.html b/examples/app_hub/index.html
index 157306c..d1f397d 100644
--- a/examples/app_hub/index.html
+++ b/examples/app_hub/index.html
@@ -11,8 +11,8 @@
})();
-
-
+
+
@@ -158,7 +158,7 @@
var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover'));
var logoEnabled = !!(stage && supportsSvg && supportsLayout);
- var logoBuild = '20260530t';
+ var logoBuild = '20260601earth3d';
function hubAsset(rel) {
var link = document.querySelector('link[href*="hub.css"]');
@@ -224,30 +224,57 @@
return base + '?v=' + logoBuild;
}
- if (logoEnabled) {
- list.forEach(function (name) {
- var img = document.createElement('img');
- img.className = 'hub-logo-layer hub-logo-layer--' + name;
- img.src = fragmentSrc(name);
- img.alt = '';
- img.decoding = 'async';
- img.loading = 'lazy';
- if (name === 'compass') {
- img.id = 'hub-compass-layer';
- img.setAttribute('role', 'button');
- img.setAttribute('tabindex', '0');
- img.setAttribute('aria-label', 'Open deployment model');
- }
- stage.appendChild(img);
- });
+ var useGlobe3d = logoEnabled && (params.get('globe3d') || '1') !== '0';
+ var globe3dSkip = { globe: true, continents: true, gridOverlay: true };
- var logoOpacity = params.get('logoOpacity');
- if (logoOpacity) {
- stage.style.opacity = logoOpacity;
+ function appendLogoLayers() {
+ list.forEach(function (name) {
+ if (useGlobe3d && globe3dSkip[name]) return;
+ var img = document.createElement('img');
+ img.className = 'hub-logo-layer hub-logo-layer--' + name;
+ img.src = fragmentSrc(name);
+ img.alt = '';
+ img.decoding = 'async';
+ img.loading = 'lazy';
+ if (name === 'compass') {
+ img.id = 'hub-compass-layer';
+ img.setAttribute('role', 'button');
+ img.setAttribute('tabindex', '0');
+ img.setAttribute('aria-label', 'Open deployment model');
+ }
+ stage.appendChild(img);
+ });
}
- stage.hidden = false;
- stage.setAttribute('aria-hidden', 'true');
+ if (logoEnabled) {
+ appendLogoLayers();
+
+ var logoOpacity = params.get('logoOpacity');
+ if (logoOpacity) {
+ stage.style.opacity = logoOpacity;
+ }
+
+ stage.hidden = false;
+ stage.setAttribute('aria-hidden', 'true');
+
+ if (useGlobe3d) {
+ var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ var globeModuleUrl = new URL('assets/hub-globe.js', window.location.href);
+ globeModuleUrl.searchParams.set('v', logoBuild);
+ import(globeModuleUrl.href).then(function (mod) {
+ return mod.mountHubGlobe(stage, { build: logoBuild, reducedMotion: reducedMotion });
+ }).catch(function (err) {
+ console.warn('HubGlobe: falling back to static layers', err);
+ ['globe', 'continents', 'gridOverlay'].forEach(function (name) {
+ if (list.indexOf(name) === -1) return;
+ var img = document.createElement('img');
+ img.className = 'hub-logo-layer hub-logo-layer--' + name;
+ img.src = fragmentSrc(name);
+ img.alt = '';
+ stage.appendChild(img);
+ });
+ });
+ }
}
/* Nav dock overlaps globe left hemisphere; stack top → bottom. */