diff --git a/examples/app_hub/assets/hub-globe.js b/examples/app_hub/assets/hub-globe.js index 5428895..8c51ff9 100644 --- a/examples/app_hub/assets/hub-globe.js +++ b/examples/app_hub/assets/hub-globe.js @@ -1,15 +1,15 @@ /** * Hub landing globe — orthographic Canvas2D sphere. - * Matches globe3d=0 reference: ocean from hub-logo-fragment-globe.svg, - * continents from hub-logo-continents-mercator-globe.svg (body-fixed orthographic disk). - * Y-axis spin; land only on front body face (z > 0); grid projected in 3D with depth weighting. + * Reference stack (globe3d=0): hub-logo-fragment-globe + continents-mercator-globe. + * Body frame uses 180° flip around equator (X) so N/S matches the static hub. */ 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.02; +const VIEW_Z_EPS = 0.001; +const BODY_FRONT_Z = 0.001; function hubAsset(rel, build) { const link = document.querySelector('link[href*="hub.css"]'); @@ -67,18 +67,22 @@ function sampleImageData(imgData, w, h, x, y) { return [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]]; } -/** View unit sphere (nx,ny,nz) → body-fixed unit sphere before Y spin. */ +/** 180° about equator (X): swap S↔N to match globe3d=0 hub orientation. */ +function flipSouthNorth(p) { + return { x: p.x, y: -p.y, z: -p.z }; +} + function inverseRotateY(nx, ny, nz, rotY) { const cosR = Math.cos(rotY); const sinR = Math.sin(rotY); - return { + return flipSouthNorth({ x: nx * cosR - nz * sinR, y: ny, z: nx * sinR + nz * cosR, - }; + }); } -function rotateY(x, y, z, rotY) { +function rotateYRaw(x, y, z, rotY) { return { x: x * Math.cos(rotY) + z * Math.sin(rotY), y, @@ -86,7 +90,15 @@ function rotateY(x, y, z, rotY) { }; } -/** Orthographic hub disk coords for body point on unit sphere (same as static SVG stack). */ +function sphereLatLon(lon, lat) { + const cosLat = Math.cos(lat); + return flipSouthNorth({ + x: cosLat * Math.sin(lon), + y: Math.sin(lat), + z: cosLat * Math.cos(lon), + }); +} + function bodyToHubPixel(xb, yb) { return { x: Math.round(GLOBE_VIEW.cx + xb * GLOBE_VIEW.r), @@ -94,30 +106,55 @@ function bodyToHubPixel(xb, yb) { }; } -/** - * Sample globe3d=0 continent + ocean stack in body-fixed frame. - * Continents layer is authoritative where opaque; else globe ocean shading. - */ -function sampleBodySurface(continents, globe, xb, yb, zb) { - if (zb <= FRONT_Z) { - return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b]; - } - const p = bodyToHubPixel(xb, yb); - const land = sampleImageData(continents.data, continents.w, continents.h, p.x, p.y); - if (land[3] > 24) { - return [land[0], land[1], land[2]]; - } - const ocean = sampleImageData(globe.data, globe.w, globe.h, p.x, p.y); - if (ocean[3] < 8) { - return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b]; - } - return [ocean[0], ocean[1], ocean[2]]; +function sampleMercatorFlat(merc, lon, lat) { + const u = (lon + Math.PI) / (2 * Math.PI); + const v = 0.5 - lat / Math.PI; + 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); +} + +function isLandPixel(r, g, b, a) { + return a > 12 && r > 100 && g < 140 && b < 140; +} + +function bodyLonLat(xb, yb, zb) { + return { + lon: Math.atan2(xb, zb), + lat: Math.asin(Math.max(-1, Math.min(1, yb))), + }; } /** - * Visible orthographic disk → inverse Y-rotation → body-fixed texture (globe3d=0 reference). + * globe3d=0 orthographic disk (primary) + mercator-flat lon/lat fill during rotation. */ -function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe) { +function sampleBodySurface(continents, globe, mercFlat, xb, yb, zb) { + const p = bodyToHubPixel(xb, yb); + const ocean = sampleImageData(globe.data, globe.w, globe.h, p.x, p.y); + const oceanRgb = + ocean[3] < 8 + ? [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b] + : [ocean[0], ocean[1], ocean[2]]; + + if (zb <= BODY_FRONT_Z) { + return oceanRgb; + } + + const ortho = sampleImageData(continents.data, continents.w, continents.h, p.x, p.y); + if (ortho[3] > 20) { + return [ortho[0], ortho[1], ortho[2]]; + } + + const ll = bodyLonLat(xb, yb, zb); + const flat = sampleMercatorFlat(mercFlat, ll.lon, ll.lat); + if (isLandPixel(flat[0], flat[1], flat[2], flat[3])) { + return [flat[0], flat[1], flat[2]]; + } + + return oceanRgb; +} + +function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe, mercFlat) { const r2 = r * r; const x0 = Math.max(0, Math.floor(cx - r)); const y0 = Math.max(0, Math.floor(cy - r)); @@ -147,7 +184,7 @@ function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe) { const ny = oy / r; const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny)); const body = inverseRotateY(nx, ny, nz, rotY); - const rgb = sampleBodySurface(continents, globe, body.x, body.y, body.z); + const rgb = sampleBodySurface(continents, globe, mercFlat, body.x, body.y, body.z); px[p++] = rgb[0]; px[p++] = rgb[1]; @@ -164,31 +201,61 @@ function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe) { ctx.restore(); } -function strokeCurve3D(ctx, cx, cy, r, rotY, pointAt, styleAt) { - let prev = null; - const steps = 72; +/** Draw each contiguous visible arc of a 3D curve (latitude circles stay on the surface). */ +function strokeVisibleArcs3D(ctx, cx, cy, r, rotY, pointAt, styleAt, steps) { + const samples = []; 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; + const p = rotateYRaw(raw.x, raw.y, raw.z, rotY); + if (p.z <= VIEW_Z_EPS) { + samples.push(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); + samples.push({ + sx: cx + p.x * r, + sy: cy - p.y * r, + z: p.z, + }); + } + + let run = []; + function flushRun() { + if (run.length < 2) { + run = []; + return; + } + for (let i = 1; i < run.length; i++) { + const a = run[i - 1]; + const b = run[i]; + const style = styleAt((a.z + b.z) * 0.5); ctx.beginPath(); - ctx.moveTo(prev.sx, prev.sy); - ctx.lineTo(sx, sy); + ctx.moveTo(a.sx, a.sy); + ctx.lineTo(b.sx, b.sy); ctx.strokeStyle = style.color; ctx.lineWidth = style.width; ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; ctx.stroke(); } - prev = { sx, sy, z: p.z }; + run = []; } + + for (let i = 0; i < samples.length; i++) { + if (!samples[i]) { + flushRun(); + } else { + run.push(samples[i]); + } + } + flushRun(); +} + +function depthStyle(baseWidth, centerAlpha, edgeAlpha, z) { + const t = Math.max(0, Math.min(1, z / 0.95)); + return { + width: baseWidth * (0.3 + 0.7 * t), + color: 'rgba(238,245,255,' + (edgeAlpha + (centerAlpha - edgeAlpha) * t).toFixed(3) + ')', + }; } function drawGridOverlay(ctx, rect, rotY) { @@ -196,6 +263,7 @@ function drawGridOverlay(ctx, rect, rotY) { const cy = rect.cy; const r = rect.r; const scale = rect.scale; + const steps = 128; ctx.save(); ctx.beginPath(); @@ -205,19 +273,9 @@ function drawGridOverlay(ctx, rect, rotY) { 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 lwEq = Math.max(2, scale * 0.007); - 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) + ')', - }; - }; - } - - strokeCurve3D( + strokeVisibleArcs3D( ctx, cx, cy, @@ -225,23 +283,21 @@ function drawGridOverlay(ctx, rect, rotY) { rotY, function (t) { const lon = -Math.PI + t * 2 * Math.PI; - return { x: Math.sin(lon), y: 0, z: Math.cos(lon) }; + return sphereLatLon(lon, 0); }, function (z) { - const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (1 - FRONT_Z))); - const alpha = 0.45 + 0.43 * t; + const t = Math.max(0, Math.min(1, z / 0.95)); return { - width: Math.max(2, scale * 0.007) * (0.4 + 0.6 * t), - color: 'rgba(219,231,246,' + alpha.toFixed(3) + ')', + width: lwEq * (0.35 + 0.65 * t), + color: 'rgba(219,231,246,' + (0.42 + 0.46 * t).toFixed(3) + ')', }; - } + }, + steps ); [-0.25, 0.25].forEach(function (latFrac) { - const latRad = latFrac * Math.PI; - const cosLat = Math.cos(latRad); - const sinLat = Math.sin(latRad); - strokeCurve3D( + const lat = latFrac * Math.PI; + strokeVisibleArcs3D( ctx, cx, cy, @@ -249,22 +305,25 @@ function drawGridOverlay(ctx, rect, rotY) { rotY, function (t) { const lon = -Math.PI + t * 2 * Math.PI; - return { x: cosLat * Math.sin(lon), y: sinLat, z: cosLat * Math.cos(lon) }; + return sphereLatLon(lon, lat); }, function (z) { + const t = Math.max(0, Math.min(1, z / 0.95)); return { - width: lwThin * (0.35 + 0.65 * Math.max(0, (z - FRONT_Z) / (1 - FRONT_Z))), - color: 'rgba(231,236,243,' + (0.2 + 0.52 * Math.max(0, (z - FRONT_Z) / (1 - FRONT_Z))).toFixed(3) + ')', + width: lwThin * (0.35 + 0.65 * t), + color: 'rgba(231,236,243,' + (0.18 + 0.54 * t).toFixed(3) + ')', }; - } + }, + steps ); }); for (let lonDeg = -150; lonDeg <= 150; lonDeg += 30) { - const lonRad = (lonDeg * Math.PI) / 180; + const lon = (lonDeg * Math.PI) / 180; const isPrime = lonDeg === 0; - const styleFn = depthStyle(isPrime ? lwPrime : lwBase, isPrime ? 0.98 : 0.78, 0.18); - strokeCurve3D( + const bw = isPrime ? lwPrime : lwBase; + const ca = isPrime ? 0.98 : 0.78; + strokeVisibleArcs3D( ctx, cx, cy, @@ -272,12 +331,12 @@ function drawGridOverlay(ctx, rect, rotY) { 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) }; + return sphereLatLon(lon, lat); }, function (z) { - return styleFn(z); - } + return depthStyle(bw, ca, 0.15, z); + }, + 96 ); } @@ -323,14 +382,17 @@ 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), + mercatorFlat: hubAsset('assets/hub-logo-continents-mercator-flat.svg', build), }; let globeImg; let continentsImg; + let mercatorFlatImg; try { - [globeImg, continentsImg] = await Promise.all([ + [globeImg, continentsImg, mercatorFlatImg] = await Promise.all([ loadImage(urls.globe), loadImage(urls.continents), + loadImage(urls.mercatorFlat), ]); } catch (e) { console.warn('HubGlobe: asset load failed', e); @@ -348,6 +410,15 @@ export async function mountHubGlobe(stage, opts) { h: GLOBE_VIEW.imgH, data: rasterizeImage(continentsImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH).data, }; + const mercFlat = { + w: mercatorFlatImg.naturalWidth || 800, + h: mercatorFlatImg.naturalHeight || 519, + data: rasterizeImage( + mercatorFlatImg, + mercatorFlatImg.naturalWidth || 800, + mercatorFlatImg.naturalHeight || 519 + ).data, + }; let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S; @@ -386,7 +457,7 @@ export async function mountHubGlobe(stage, opts) { const drawRect = { cx, cy, r, rim, scale: rect.scale * dpr }; ctx.clearRect(0, 0, px, px); - drawGlobeDisk(ctx, cx, cy, r, rotation, continents, globe); + drawGlobeDisk(ctx, cx, cy, r, rotation, continents, globe, mercFlat); drawRim(ctx, globeImg, cx, cy, r, rim, rect.scale * dpr); drawGridOverlay(ctx, drawRect, rotation); ctx.save(); diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index f73a767..223106c 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 = '20260606globe2'; + var logoBuild = '20260606globe3'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]');