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. */