diff --git a/examples/app_hub/README.md b/examples/app_hub/README.md index d7415e9..7b2e3b1 100644 --- a/examples/app_hub/README.md +++ b/examples/app_hub/README.md @@ -13,9 +13,7 @@ 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`). +**3D globe background** (default): Canvas2D orthographic sphere — ocean from `hub-logo-fragment-globe.svg`, continents from `hub-logo-continents-mercator-flat.svg` (front hemisphere only), Mercator grid projected in 3D with depth-weighted lines. Slow Y-axis spin (~0.035 rad/s); drag horizontally to rotate. Disable: `?globe3d=0` (static SVG stack). Yaw override: `?globeYawDeg=24`. Deploy `assets/hub-globe.js` with `index.php` (see `DEPLOY.md`). **BE only.** FE `apps.f0xx.org` sends `location /` → `http://artc0.intra.raptor.org:80` (not :8089). diff --git a/examples/app_hub/assets/hub-globe.js b/examples/app_hub/assets/hub-globe.js index f4cfa3e..1c1b78c 100644 --- a/examples/app_hub/assets/hub-globe.js +++ b/examples/app_hub/assets/hub-globe.js @@ -1,17 +1,14 @@ /** - * 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). + * Hub landing globe — orthographic Canvas2D sphere. + * Ocean shading from hub-logo-fragment-globe.svg; continents from mercator-flat.svg. + * Y-axis spin with front-hemisphere-only land; grid lines are 3D-projected with depth weighting. */ 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 }; +const FRONT_Z = 0.02; function hubAsset(rel, build) { const link = document.querySelector('link[href*="hub.css"]'); @@ -53,105 +50,57 @@ function loadImage(url) { }); } -function compositeStaticLayers(globeImg, continentsImg, gridImg) { +function rasterizeImage(img, w, h) { const c = document.createElement('canvas'); - c.width = GLOBE_VIEW.imgW; - c.height = GLOBE_VIEW.imgH; + c.width = w; + c.height = h; 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) }; + ctx.drawImage(img, 0, 0, w, h); + return ctx.getImageData(0, 0, w, h); } -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 sampleImageData(imgData, w, h, x, y) { + const ix = Math.max(0, Math.min(w - 1, x | 0)); + const iy = Math.max(0, Math.min(h - 1, y | 0)); + const i = (iy * w + ix) * 4; + return [ + imgData[i], + imgData[i + 1], + imgData[i + 2], + imgData[i + 3], + ]; } -function sampleMercatorFlat(ctx, lon, lat) { - const w = ctx.canvas.width; - const h = ctx.canvas.height; +function isLandPixel(r, g, b, a) { + return a > 8 && r > 120 && g < 130 && b < 130; +} + +function sampleMercator(merc, lon, lat) { 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]]; + const x = Math.round(u * (merc.w - 1)); + const y = Math.round(v * (merc.h - 1)); + return sampleImageData(merc.data, merc.w, merc.h, x, y); } -/** - * 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 = [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; - } +function sampleGlobeOcean(globe, xw, yw) { + const sx = Math.round(GLOBE_VIEW.cx + xw * GLOBE_VIEW.r); + const sy = Math.round(GLOBE_VIEW.cy - yw * GLOBE_VIEW.r); + const rgba = sampleImageData(globe.data, globe.w, globe.h, sx, sy); + if (rgba[3] < 8) { + return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; } - - eq.getContext('2d').putImageData(out, 0, 0); - return eq; + return rgba; } -function drawEquirectSlice(ctx, eqCanvas, rotation, dx, dy, diam) { - const w = eqCanvas.width; - const h = eqCanvas.height; - const ox = ((rotation / (2 * Math.PI)) * w % w + w) % w; - const destX = dx - diam * 0.5; - const destY = dy - diam * 0.5; - - ctx.save(); - ctx.beginPath(); - ctx.arc(dx, dy, diam * 0.5, 0, Math.PI * 2); - ctx.clip(); - - const slice1w = w - ox; - const destSlice1 = diam * (slice1w / w); - ctx.drawImage(eqCanvas, ox, 0, slice1w, h, destX, destY, destSlice1, diam); - if (ox > 0.5) { - ctx.drawImage(eqCanvas, 0, 0, ox, h, destX + destSlice1, destY, diam - destSlice1, diam); - } - ctx.restore(); +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 rotateY(x, y, z, rotY) { @@ -162,80 +111,197 @@ function rotateY(x, y, z, rotY) { }; } -function strokeParallel(ctx, cx, cy, r, latRad, rotY, strokeStyle, lineWidth) { - const pts = []; - for (let i = 0; i <= 64; i++) { - const lon = -Math.PI + (i / 64) * 2 * Math.PI; - const cosLat = Math.cos(latRad); - const p = rotateY(cosLat * Math.sin(lon), Math.sin(latRad), cosLat * Math.cos(lon), rotY); - if (p.z > 0.04) { - pts.push({ x: cx + p.x * r, y: cy - p.y * r }); +/** + * Orthographic disk → world lon/lat → mercator land + globe ocean (front face only). + */ +function drawGlobeDisk(ctx, cx, cy, r, rotY, merc, globe) { + 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; + + 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; + const d2 = ox * ox + oy * oy; + if (d2 > 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 wpos = inverseRotateY(nx, ny, nz, rotY); + const lon = Math.atan2(wpos.x, wpos.z); + const lat = Math.asin(Math.max(-1, Math.min(1, wpos.y))); + + let r8; + let g8; + let b8; + if (wpos.z > FRONT_Z) { + const land = sampleMercator(merc, lon, lat); + if (isLandPixel(land[0], land[1], land[2], land[3])) { + r8 = land[0]; + g8 = land[1]; + b8 = land[2]; + } else { + const ocean = sampleGlobeOcean(globe, wpos.x, wpos.y); + r8 = ocean[0]; + g8 = ocean[1]; + b8 = ocean[2]; + } + } else { + r8 = OCEAN_FALLBACK.r; + g8 = OCEAN_FALLBACK.g; + b8 = OCEAN_FALLBACK.b; + } + + px[p++] = r8; + px[p++] = g8; + px[p++] = b8; + px[p++] = 255; } } - if (pts.length < 2) return; + + ctx.save(); 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(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.clip(); + ctx.putImageData(out, x0, y0); + 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); - const p = rotateY(cosLat * Math.sin(lonRad), Math.sin(lat), cosLat * Math.cos(lonRad), rotY); - if (p.z > 0.04) { - pts.push({ x: cx + p.x * r, y: cy - p.y * r }); +function strokeCurve3D(ctx, cx, cy, r, rotY, pointAt, styleAt) { + let prev = null; + const steps = 72; + for (let i = 0; i <= steps; i++) { + const raw = pointAt(i / steps); + const p = rotateY(raw.x, raw.y, raw.z, rotY); + if (p.z <= FRONT_Z) { + prev = null; + continue; } + const sx = cx + p.x * r; + const sy = cy - p.y * r; + if (prev) { + const zMid = (prev.z + p.z) * 0.5; + const style = styleAt(zMid, i, steps); + ctx.beginPath(); + ctx.moveTo(prev.sx, prev.sy); + ctx.lineTo(sx, sy); + ctx.strokeStyle = style.color; + ctx.lineWidth = style.width; + ctx.lineCap = 'round'; + ctx.stroke(); + } + prev = { sx, sy, z: p.z }; } - 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; + const scale = rect.scale; 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)'); + const lwBase = Math.max(2.5, scale * 0.008); + const lwPrime = Math.max(3, scale * 0.0095); + const lwThin = Math.max(1.5, scale * 0.0055); - const meridianStroke = 'rgba(238,245,255,0.92)'; - const primeStroke = 'rgba(238,245,255,0.98)'; - const parallelStroke = 'rgba(231,236,243,0.72)'; - const lw = Math.max(2.5, rect.scale * 0.008); - const lwPrime = Math.max(3, rect.scale * 0.0095); - const lwThin = Math.max(1.5, rect.scale * 0.0055); + function depthStyle(baseWidth, centerAlpha, edgeAlpha) { + return function (z) { + const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (1 - FRONT_Z))); + const alpha = edgeAlpha + (centerAlpha - edgeAlpha) * t; + return { + width: baseWidth * (0.35 + 0.65 * t), + color: 'rgba(238,245,255,' + alpha.toFixed(3) + ')', + }; + }; + } - strokeParallel(ctx, cx, cy, r, 0, rotY, equatorGrad, Math.max(2, rect.scale * 0.007)); - strokeParallel(ctx, cx, cy, r, Math.PI * 0.25, rotY, parallelStroke, lwThin); - strokeParallel(ctx, cx, cy, r, -Math.PI * 0.25, rotY, parallelStroke, lwThin); + strokeCurve3D( + ctx, + cx, + cy, + r, + rotY, + function (t) { + const lon = -Math.PI + t * 2 * Math.PI; + return { x: Math.sin(lon), y: 0, z: Math.cos(lon) }; + }, + function (z) { + const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (1 - FRONT_Z))); + const alpha = 0.45 + 0.43 * t; + return { + width: Math.max(2, scale * 0.007) * (0.4 + 0.6 * t), + color: 'rgba(219,231,246,' + alpha.toFixed(3) + ')', + }; + } + ); + + [-0.25, 0.25].forEach(function (latFrac) { + const latRad = latFrac * Math.PI; + const cosLat = Math.cos(latRad); + const sinLat = Math.sin(latRad); + const styleFn = depthStyle(lwThin, 0.55, 0.2); + strokeCurve3D( + ctx, + cx, + cy, + r, + rotY, + function (t) { + const lon = -Math.PI + t * 2 * Math.PI; + return { x: cosLat * Math.sin(lon), y: sinLat, z: cosLat * Math.cos(lon) }; + }, + function (z) { + const s = styleFn(z); + return { width: s.width, color: 'rgba(231,236,243,' + (0.2 + 0.52 * Math.max(0, (z - FRONT_Z) / (1 - FRONT_Z))).toFixed(3) + ')' }; + } + ); + }); for (let lonDeg = -150; lonDeg <= 150; lonDeg += 30) { - const lon = (lonDeg * Math.PI) / 180; + const lonRad = (lonDeg * Math.PI) / 180; const isPrime = lonDeg === 0; - strokeMeridian(ctx, cx, cy, r, lon, rotY, isPrime ? primeStroke : meridianStroke, isPrime ? lwPrime : lw); + const styleFn = depthStyle(isPrime ? lwPrime : lwBase, isPrime ? 0.98 : 0.78, 0.18); + strokeCurve3D( + ctx, + cx, + cy, + r, + rotY, + function (t) { + const lat = -Math.PI / 2 + t * Math.PI; + const cosLat = Math.cos(lat); + return { x: cosLat * Math.sin(lonRad), y: Math.sin(lat), z: cosLat * Math.cos(lonRad) }; + }, + function (z) { + const s = styleFn(z); + return s; + } + ); } ctx.beginPath(); - ctx.arc(cx, cy, 4, 0, Math.PI * 2); + ctx.arc(cx, cy, Math.max(3, scale * 0.01), 0, Math.PI * 2); ctx.fillStyle = '#5eead4'; ctx.fill(); ctx.strokeStyle = '#0a0e14'; @@ -245,24 +311,24 @@ function drawGridOverlay(ctx, rect, rotY) { 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; +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(compositeCanvas, 0, 0, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, dx, dy, drawW, drawH); + ctx.drawImage(globeImg, 0, 0, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, dx, dy, drawW, drawH); ctx.restore(); } /** * @param {HTMLElement} stage - * @param {{ build: string, reducedMotion?: boolean }} opts + * @param {{ build: string, reducedMotion?: boolean, initialRotationY?: number }} opts */ export async function mountHubGlobe(stage, opts) { const build = opts.build || 'earth3d'; @@ -275,20 +341,14 @@ export async function mountHubGlobe(stage, opts) { 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([ + [globeImg, mercatorFlatImg] = await Promise.all([ loadImage(urls.globe), - loadImage(urls.continents), - loadImage(urls.grid), loadImage(urls.mercatorFlat), ]); } catch (e) { @@ -297,10 +357,18 @@ export async function mountHubGlobe(stage, opts) { return null; } - const composite = compositeStaticLayers(globeImg, continentsImg, gridImg); - const eqCanvas = bakeEquirectangular(composite.data, mercatorFlatImg); + const globe = { + w: GLOBE_VIEW.imgW, + h: GLOBE_VIEW.imgH, + data: rasterizeImage(globeImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH).data, + }; + const merc = { + w: mercatorFlatImg.naturalWidth || 800, + h: mercatorFlatImg.naturalHeight || 519, + data: rasterizeImage(mercatorFlatImg, mercatorFlatImg.naturalWidth || 800, mercatorFlatImg.naturalHeight || 519).data, + }; - let rotation = 0; + let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S; let dragging = false; let lastPointerX = 0; @@ -308,7 +376,7 @@ export async function mountHubGlobe(stage, opts) { let raf = 0; let lastFrame = performance.now(); - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); const resize = () => { const rect = globeCoverRect(); @@ -340,12 +408,11 @@ export async function mountHubGlobe(stage, opts) { r, rim, scale: rect.scale * dpr, - left: 0, - top: 0, }; ctx.clearRect(0, 0, px, px); - drawEquirectSlice(ctx, eqCanvas, rotation, cx, cy, r * 2); + drawGlobeDisk(ctx, cx, cy, r, rotation, merc, globe); + drawRim(ctx, globeImg, cx, cy, r, rim, rect.scale * dpr); drawGridOverlay(ctx, drawRect, rotation); ctx.save(); ctx.beginPath(); diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 7c8f9a0..7fb9af2 100644 --- a/examples/app_hub/index.php +++ b/examples/app_hub/index.php @@ -180,7 +180,7 @@ unset($__platformDir); var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover')); var logoEnabled = !!(stage && supportsSvg && supportsLayout); - var logoBuild = '20260606landing'; + var logoBuild = '20260606globe'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]');