diff --git a/examples/app_hub/assets/hub-globe.js b/examples/app_hub/assets/hub-globe.js index 8c51ff9..0842d21 100644 --- a/examples/app_hub/assets/hub-globe.js +++ b/examples/app_hub/assets/hub-globe.js @@ -1,15 +1,16 @@ /** - * Hub landing globe — orthographic Canvas2D sphere. - * 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. + * Hub landing globe — orthographic Canvas2D (globe3d=1). + * Layer order matches globe3d=0 SVG stack: ocean → grid → continents. + * Land baked to equirectangular (ortho ref + mercator-flat wrap); grid under continents. */ 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 VIEW_Z_EPS = 0.001; -const BODY_FRONT_Z = 0.001; +const EQ_W = 1024; +const EQ_H = 512; +const VIEW_Z_MIN = 0.06; function hubAsset(rel, build) { const link = document.querySelector('link[href*="hub.css"]'); @@ -67,7 +68,6 @@ function sampleImageData(imgData, w, h, x, y) { return [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]]; } -/** 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 }; } @@ -106,6 +106,13 @@ function bodyToHubPixel(xb, yb) { }; } +function lonLatFromBody(body) { + return { + lon: Math.atan2(body.x, body.z), + lat: Math.asin(Math.max(-1, Math.min(1, body.y))), + }; +} + function sampleMercatorFlat(merc, lon, lat) { const u = (lon + Math.PI) / (2 * Math.PI); const v = 0.5 - lat / Math.PI; @@ -114,47 +121,75 @@ function sampleMercatorFlat(merc, lon, lat) { return sampleImageData(merc.data, merc.w, merc.h, x, y); } -function isLandPixel(r, g, b, a) { +function sampleEquatorLand(landEq, lon, lat) { + const u = (lon + Math.PI) / (2 * Math.PI); + const v = 0.5 - lat / Math.PI; + const x = Math.round(u * (EQ_W - 1)); + const y = Math.round(v * (EQ_H - 1)); + return sampleImageData(landEq.data, EQ_W, EQ_H, x, y); +} + +function isLandMask(r, g, b, a) { + return a > 16; +} + +function isFlatLand(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))), - }; +/** Equirect land: ortho globe3d=0 colors on front body + mercator-flat elsewhere. */ +function bakeLandEquator(continents, mercFlat) { + const out = new Uint8ClampedArray(EQ_W * EQ_H * 4); + for (let j = 0; j < EQ_H; j++) { + for (let i = 0; i < EQ_W; i++) { + const lon = (i / EQ_W) * 2 * Math.PI - Math.PI; + const lat = Math.PI * 0.5 - (j / EQ_H) * Math.PI; + const body = sphereLatLon(lon, lat); + const p = (j * EQ_W + i) * 4; + let rgba = [0, 0, 0, 0]; + + if (body.z > 0) { + const hp = bodyToHubPixel(body.x, body.y); + const ortho = sampleImageData(continents.data, continents.w, continents.h, hp.x, hp.y); + if (ortho[3] > 20) { + rgba = [ortho[0], ortho[1], ortho[2], 255]; + } + } + if (rgba[3] < 16) { + const flat = sampleMercatorFlat(mercFlat, lon, lat); + if (isFlatLand(flat[0], flat[1], flat[2], flat[3])) { + rgba = [flat[0], flat[1], flat[2], 220]; + } + } + + out[p] = rgba[0]; + out[p + 1] = rgba[1]; + out[p + 2] = rgba[2]; + out[p + 3] = rgba[3]; + } + } + return { data: out, w: EQ_W, h: EQ_H }; } -/** - * globe3d=0 orthographic disk (primary) + mercator-flat lon/lat fill during rotation. - */ -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 viewDirBody(rotY) { + return inverseRotateY(0, 0, 1, rotY); } -function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe, mercFlat) { +function facingCamera(body, rotY) { + const vd = viewDirBody(rotY); + return body.x * vd.x + body.y * vd.y + body.z * vd.z > 0.002; +} + +function sampleOcean(globe, xb, yb) { + const hp = bodyToHubPixel(xb, yb); + const o = sampleImageData(globe.data, globe.w, globe.h, hp.x, hp.y); + if (o[3] < 8) { + return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b]; + } + return [o[0], o[1], o[2]]; +} + +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)); @@ -162,7 +197,7 @@ function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe, mercFlat) { 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; + if (w <= 0 || h <= 0) return null; const out = ctx.createImageData(w, h); const px = out.data; @@ -172,8 +207,7 @@ function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe, mercFlat) { for (let dx = x0; dx <= x1; dx++) { const ox = dx - cx; const oy = dy - cy; - const d2 = ox * ox + oy * oy; - if (d2 > r2) { + if (ox * ox + oy * oy > r2) { px[p++] = 0; px[p++] = 0; px[p++] = 0; @@ -184,38 +218,60 @@ function drawGlobeDisk(ctx, cx, cy, r, rotY, continents, globe, mercFlat) { 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, mercFlat, body.x, body.y, body.z); - + const rgb = fn(body); px[p++] = rgb[0]; px[p++] = rgb[1]; px[p++] = rgb[2]; - px[p++] = 255; + px[p++] = rgb[3] !== undefined ? rgb[3] : 255; } } + return { out, x0, y0 }; +} + +function blitLayer(ctx, cx, cy, r, layer) { + if (!layer) return; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); - ctx.putImageData(out, x0, y0); + ctx.putImageData(layer.out, layer.x0, layer.y0); ctx.restore(); } -/** Draw each contiguous visible arc of a 3D curve (latitude circles stay on the surface). */ +function drawOceanLayer(ctx, cx, cy, r, rotY, globe) { + const layer = fillDiskRegion(ctx, cx, cy, r, rotY, function (body) { + return sampleOcean(globe, body.x, body.y).concat([255]); + }); + blitLayer(ctx, cx, cy, r, layer); +} + +function drawContinentsLayer(ctx, cx, cy, r, rotY, landEq) { + const layer = fillDiskRegion(ctx, cx, cy, r, rotY, function (body) { + if (!facingCamera(body, rotY)) { + return [0, 0, 0, 0]; + } + const ll = lonLatFromBody(body); + const land = sampleEquatorLand(landEq, ll.lon, ll.lat); + if (!isLandMask(land[0], land[1], land[2], land[3])) { + return [0, 0, 0, 0]; + } + return [land[0], land[1], land[2], land[3]]; + }); + blitLayer(ctx, cx, cy, r, layer); +} + +/** One continuous path per visible arc — parallels stay on the sphere. */ 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 = rotateYRaw(raw.x, raw.y, raw.z, rotY); - if (p.z <= VIEW_Z_EPS) { + if (p.z <= VIEW_Z_MIN) { samples.push(null); continue; } - samples.push({ - sx: cx + p.x * r, - sy: cy - p.y * r, - z: p.z, - }); + samples.push({ sx: cx + p.x * r, sy: cy - p.y * r, z: p.z }); } let run = []; @@ -224,37 +280,51 @@ function strokeVisibleArcs3D(ctx, cx, cy, r, rotY, pointAt, styleAt, steps) { run = []; return; } + let zSum = 0; + ctx.beginPath(); + ctx.moveTo(run[0].sx, run[0].sy); 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(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(); + ctx.lineTo(run[i].sx, run[i].sy); + zSum += run[i].z; } + const style = styleAt(zSum / run.length); + ctx.strokeStyle = style.color; + ctx.lineWidth = style.width; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); run = []; } for (let i = 0; i < samples.length; i++) { - if (!samples[i]) { - flushRun(); - } else { - run.push(samples[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)); +function meridianStyle(isPrime, scale, z) { + const t = Math.max(0, Math.min(1, (z - VIEW_Z_MIN) / (0.92 - VIEW_Z_MIN))); + if (t <= 0) return { width: 0, color: 'rgba(0,0,0,0)' }; + const bw = isPrime ? Math.max(3, scale * 0.0095) : Math.max(2.5, scale * 0.008); + const ca = isPrime ? 0.98 : 0.72; + const ea = 0.02; return { - width: baseWidth * (0.3 + 0.7 * t), - color: 'rgba(238,245,255,' + (edgeAlpha + (centerAlpha - edgeAlpha) * t).toFixed(3) + ')', + width: bw * (0.25 + 0.75 * t * t), + color: 'rgba(238,245,255,' + (ea + (ca - ea) * t * t).toFixed(3) + ')', + }; +} + +function parallelStyle(scale, z, bright) { + const t = Math.max(0, Math.min(1, (z - VIEW_Z_MIN) / (0.92 - VIEW_Z_MIN))); + if (t <= 0) return { width: 0, color: 'rgba(0,0,0,0)' }; + const bw = bright ? Math.max(2, scale * 0.007) : Math.max(1.5, scale * 0.0055); + const ca = bright ? 0.88 : 0.55; + return { + width: bw * (0.3 + 0.7 * t), + color: bright + ? 'rgba(219,231,246,' + (0.35 + 0.53 * t).toFixed(3) + ')' + : 'rgba(231,236,243,' + (0.15 + 0.45 * t).toFixed(3) + ')', }; } @@ -263,18 +333,13 @@ function drawGridOverlay(ctx, rect, rotY) { const cy = rect.cy; const r = rect.r; const scale = rect.scale; - const steps = 128; + const steps = 160; ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); - 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); - strokeVisibleArcs3D( ctx, cx, @@ -282,15 +347,10 @@ function drawGridOverlay(ctx, rect, rotY) { r, rotY, function (t) { - const lon = -Math.PI + t * 2 * Math.PI; - return sphereLatLon(lon, 0); + return sphereLatLon(-Math.PI + t * 2 * Math.PI, 0); }, function (z) { - const t = Math.max(0, Math.min(1, z / 0.95)); - return { - width: lwEq * (0.35 + 0.65 * t), - color: 'rgba(219,231,246,' + (0.42 + 0.46 * t).toFixed(3) + ')', - }; + return parallelStyle(scale, z, true); }, steps ); @@ -304,15 +364,10 @@ function drawGridOverlay(ctx, rect, rotY) { r, rotY, function (t) { - const lon = -Math.PI + t * 2 * Math.PI; - return sphereLatLon(lon, lat); + return sphereLatLon(-Math.PI + t * 2 * Math.PI, lat); }, function (z) { - const t = Math.max(0, Math.min(1, z / 0.95)); - return { - width: lwThin * (0.35 + 0.65 * t), - color: 'rgba(231,236,243,' + (0.18 + 0.54 * t).toFixed(3) + ')', - }; + return parallelStyle(scale, z, false); }, steps ); @@ -321,8 +376,6 @@ function drawGridOverlay(ctx, rect, rotY) { for (let lonDeg = -150; lonDeg <= 150; lonDeg += 30) { const lon = (lonDeg * Math.PI) / 180; const isPrime = lonDeg === 0; - const bw = isPrime ? lwPrime : lwBase; - const ca = isPrime ? 0.98 : 0.78; strokeVisibleArcs3D( ctx, cx, @@ -330,11 +383,10 @@ function drawGridOverlay(ctx, rect, rotY) { r, rotY, function (t) { - const lat = -Math.PI / 2 + t * Math.PI; - return sphereLatLon(lon, lat); + return sphereLatLon(lon, -Math.PI / 2 + t * Math.PI); }, function (z) { - return depthStyle(bw, ca, 0.15, z); + return meridianStyle(isPrime, scale, z); }, 96 ); @@ -366,10 +418,6 @@ function drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale) { ctx.restore(); } -/** - * @param {HTMLElement} stage - * @param {{ build: string, reducedMotion?: boolean, initialRotationY?: number }} opts - */ export async function mountHubGlobe(stage, opts) { const build = opts.build || 'earth3d'; const reducedMotion = !!opts.reducedMotion; @@ -419,6 +467,7 @@ export async function mountHubGlobe(stage, opts) { mercatorFlatImg.naturalHeight || 519 ).data, }; + const landEq = bakeLandEquator(continents, mercFlat); let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S; @@ -457,9 +506,10 @@ 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, mercFlat); - drawRim(ctx, globeImg, cx, cy, r, rim, rect.scale * dpr); + drawOceanLayer(ctx, cx, cy, r, rotation, globe); drawGridOverlay(ctx, drawRect, rotation); + drawContinentsLayer(ctx, cx, cy, r, rotation, landEq); + drawRim(ctx, globeImg, cx, cy, r, rim, rect.scale * dpr); ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 223106c..4f18faa 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 = '20260606globe3'; + var logoBuild = '20260606globe4'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]');