diff --git a/examples/app_hub/assets/hub-globe.js b/examples/app_hub/assets/hub-globe.js index c79522d..fdc1d11 100644 --- a/examples/app_hub/assets/hub-globe.js +++ b/examples/app_hub/assets/hub-globe.js @@ -10,6 +10,12 @@ const DRAG_ROTATION_PER_PX = 0.004; const OCEAN_FALLBACK = { r: 22, g: 38, b: 60 }; const FRONT_Z = 0.02; const RASTER_SCALE = 2; +const EQ_W = 1024; +const EQ_H = 512; +/** Hub ortho disk parallels ≈ sin(lat) offset from SVG pole ellipses. */ +const GRID_PARALLELS = [0, 0.521, -0.521]; +const GRID_MERIDIAN_SPACING = Math.PI / 6; +const GRID_LINE_RAD = 0.012; function hubAsset(rel, build) { const link = document.querySelector('link[href*="hub.css"]'); @@ -123,6 +129,92 @@ function isLandPixel(r, g, b, a) { return a > 12 && r > 100 && g < 140 && b < 140; } +function isGridPixel(r, g, b, a) { + return a > 10 && r + g + b > 280; +} + +function bodyLonLat(tex) { + return { + lon: Math.atan2(tex.x, tex.z), + lat: Math.asin(Math.max(-1, Math.min(1, tex.y))), + }; +} + +function sampleEquator(map, lon, lat) { + const u = (lon + Math.PI) / (2 * Math.PI); + const v = 0.5 - lat / Math.PI; + return sampleImageDataBilinear(map.data, map.w, map.h, u * (map.w - 1), v * (map.h - 1)); +} + +/** Procedural Mercator grid on wrap-around hemisphere (matches hub 30° meridians + pole ellipses). */ +function analyticGridRgba(lon, lat) { + const wrapped = ((lon + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI); + const cell = wrapped % GRID_MERIDIAN_SPACING; + const merDist = Math.min(cell, GRID_MERIDIAN_SPACING - cell); + const onMeridian = merDist < GRID_LINE_RAD; + let onParallel = false; + let bright = false; + for (let i = 0; i < GRID_PARALLELS.length; i++) { + const targetLat = Math.asin(Math.max(-1, Math.min(1, GRID_PARALLELS[i]))); + if (Math.abs(lat - targetLat) < GRID_LINE_RAD) { + onParallel = true; + bright = i === 0; + } + } + if (!onMeridian && !onParallel) { + return [0, 0, 0, 0]; + } + if (onMeridian) { + const prime = merDist < GRID_LINE_RAD && (Math.abs(lon) < GRID_LINE_RAD || Math.abs(Math.abs(lon) - Math.PI) < GRID_LINE_RAD); + return prime ? [238, 245, 255, 220] : [238, 245, 255, 170]; + } + return bright ? [219, 231, 246, 190] : [231, 236, 243, 130]; +} + +/** + * Equirect grid: ortho hub artwork on front body face + analytic lines on back wrap. + */ +function bakeGridEquator(gridOverlay, globe) { + 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 cosLat = Math.cos(lat); + const tex = toTextureBody({ + x: cosLat * Math.sin(lon), + y: Math.sin(lat), + z: cosLat * Math.cos(lon), + }); + const p = (j * EQ_W + i) * 4; + let rgba = [0, 0, 0, 0]; + + if (tex.z > FRONT_Z) { + const overlay = sampleOverlayRgba(gridOverlay, tex); + if (isGridPixel(overlay[0], overlay[1], overlay[2], overlay[3])) { + rgba = overlay; + } else { + const hp = hubPixelF(tex); + const sx = (hp.x / GLOBE_VIEW.imgW) * (globe.w - 1); + const sy = (hp.y / GLOBE_VIEW.imgH) * (globe.h - 1); + const base = sampleImageDataBilinear(globe.data, globe.w, globe.h, sx, sy); + if (isGridPixel(base[0], base[1], base[2], base[3])) { + rgba = base; + } + } + } else { + rgba = analyticGridRgba(lon, lat); + } + + 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 }; +} + function sampleOceanRgba(globe, tex) { const hp = hubPixelF(tex); const sx = (hp.x / GLOBE_VIEW.imgW) * (globe.w - 1); @@ -143,17 +235,18 @@ function sampleOverlayRgba(layer, tex) { function sampleLandRgba(continents, mercFlat, body) { const tex = toTextureBody(body); - const hp = hubPixelF(tex); - const sx = (hp.x / GLOBE_VIEW.imgW) * (continents.w - 1); - const sy = (hp.y / GLOBE_VIEW.imgH) * (continents.h - 1); - const ortho = sampleImageDataBilinear(continents.data, continents.w, continents.h, sx, sy); - if (ortho[3] > 6) { - return ortho; + if (tex.z > FRONT_Z) { + const hp = hubPixelF(tex); + const sx = (hp.x / GLOBE_VIEW.imgW) * (continents.w - 1); + const sy = (hp.y / GLOBE_VIEW.imgH) * (continents.h - 1); + const ortho = sampleImageDataBilinear(continents.data, continents.w, continents.h, sx, sy); + if (ortho[3] > 6) { + return ortho; + } } - const lon = Math.atan2(tex.x, tex.z); - const lat = Math.asin(Math.max(-1, Math.min(1, tex.y))); - const uv = mercatorFlatUV(lon, lat, mercFlat.w, mercFlat.h); + const ll = bodyLonLat(tex); + const uv = mercatorFlatUV(ll.lon, ll.lat, mercFlat.w, mercFlat.h); const flat = sampleImageDataBilinear(mercFlat.data, mercFlat.w, mercFlat.h, uv.x, uv.y); if (isLandPixel(flat[0], flat[1], flat[2], flat[3])) { return [flat[0], flat[1], flat[2], Math.min(255, flat[3])]; @@ -162,6 +255,31 @@ function sampleLandRgba(continents, mercFlat, body) { return null; } +function sampleGridRgba(body, gridEq, gridOverlay, globe) { + const tex = toTextureBody(body); + if (tex.z > FRONT_Z) { + const overlay = sampleOverlayRgba(gridOverlay, tex); + if (overlay[3] >= 10) { + return overlay; + } + const hp = hubPixelF(tex); + const sx = (hp.x / GLOBE_VIEW.imgW) * (globe.w - 1); + const sy = (hp.y / GLOBE_VIEW.imgH) * (globe.h - 1); + const base = sampleImageDataBilinear(globe.data, globe.w, globe.h, sx, sy); + if (isGridPixel(base[0], base[1], base[2], base[3])) { + return base; + } + return [0, 0, 0, 0]; + } + + const ll = bodyLonLat(tex); + const eq = sampleEquator(gridEq, ll.lon, ll.lat); + if (eq[3] < 8) { + return [0, 0, 0, 0]; + } + return eq; +} + function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { const r2 = r * r; const x0 = Math.max(0, Math.floor(cx - r)); @@ -214,21 +332,19 @@ function blitLayer(ctx, cx, cy, r, layer) { function drawOceanLayer(ctx, cx, cy, r, rotY, globe) { const layer = fillDiskRegion(ctx, cx, cy, r, rotY, function (body) { - if (body.z <= FRONT_Z) { - return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; + const tex = toTextureBody(body); + if (tex.z > FRONT_Z) { + return sampleOceanRgba(globe, tex); } - return sampleOceanRgba(globe, toTextureBody(body)); + return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; }); blitLayer(ctx, cx, cy, r, layer); } -function drawGridLayer(ctx, cx, cy, r, rotY, gridOverlay) { +function drawGridLayer(ctx, cx, cy, r, rotY, gridEq, gridOverlay, globe) { const layer = fillDiskRegion(ctx, cx, cy, r, rotY, function (body) { - if (body.z <= FRONT_Z) { - return [0, 0, 0, 0]; - } - const px = sampleOverlayRgba(gridOverlay, toTextureBody(body)); - if (px[3] < 10) { + const px = sampleGridRgba(body, gridEq, gridOverlay, globe); + if (px[3] < 8) { return [0, 0, 0, 0]; } return px; @@ -238,9 +354,6 @@ function drawGridLayer(ctx, cx, cy, r, rotY, gridOverlay) { function drawLandLayer(ctx, cx, cy, r, rotY, continents, mercFlat) { const layer = fillDiskRegion(ctx, cx, cy, r, rotY, function (body) { - if (body.z <= FRONT_Z) { - return [0, 0, 0, 0]; - } const rgba = sampleLandRgba(continents, mercFlat, body); if (!rgba) { return [0, 0, 0, 0]; @@ -304,6 +417,7 @@ export async function mountHubGlobe(stage, opts) { const mercW = (mercatorFlatImg.naturalWidth || 800) * 2; const mercH = (mercatorFlatImg.naturalHeight || 519) * 2; const mercFlat = rasterizeImage(mercatorFlatImg, mercW, mercH, 1); + const gridEq = bakeGridEquator(gridOverlay, globe); let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S; @@ -342,7 +456,7 @@ export async function mountHubGlobe(stage, opts) { ctx.clearRect(0, 0, px, px); drawOceanLayer(ctx, cx, cy, r, rotation, globe); - drawGridLayer(ctx, cx, cy, r, rotation, gridOverlay); + drawGridLayer(ctx, cx, cy, r, rotation, gridEq, gridOverlay, globe); drawLandLayer(ctx, cx, cy, r, rotation, continents, mercFlat); drawRim(ctx, globeImg, cx, cy, r, rim, rect.scale * dpr); ctx.save(); diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 5ee3da0..3550ce6 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 = '20260606globe6'; + var logoBuild = '20260606globe7'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]');