From 58c47722f9ebed0762554b458d73c1c107197f84 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 6 Jun 2026 18:31:12 +0200 Subject: [PATCH] test landing --- examples/app_hub/assets/hub-globe.js | 393 ++++++++------------------- examples/app_hub/index.php | 2 +- 2 files changed, 115 insertions(+), 280 deletions(-) diff --git a/examples/app_hub/assets/hub-globe.js b/examples/app_hub/assets/hub-globe.js index 0842d21..c79522d 100644 --- a/examples/app_hub/assets/hub-globe.js +++ b/examples/app_hub/assets/hub-globe.js @@ -1,16 +1,15 @@ /** * 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. + * Matches globe3d=0 stack: globe (ocean+base grid) → grid overlay → continents. + * Grid/land sampled from hub SVG disks in body-fixed frame (Mercator ortho, not geodesic lines). */ 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 EQ_W = 1024; -const EQ_H = 512; -const VIEW_Z_MIN = 0.06; +const FRONT_Z = 0.02; +const RASTER_SCALE = 2; function hubAsset(rel, build) { const link = document.querySelector('link[href*="hub.css"]'); @@ -52,141 +51,115 @@ function loadImage(url) { }); } -function rasterizeImage(img, w, h) { +function rasterizeImage(img, w, h, scale) { + const s = scale || 1; + const rw = Math.max(1, Math.round(w * s)); + const rh = Math.max(1, Math.round(h * s)); const c = document.createElement('canvas'); - c.width = w; - c.height = h; + c.width = rw; + c.height = rh; const ctx = c.getContext('2d', { willReadFrequently: true }); - ctx.drawImage(img, 0, 0, w, h); - return ctx.getImageData(0, 0, w, h); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(img, 0, 0, rw, rh); + return { data: ctx.getImageData(0, 0, rw, rh).data, w: rw, h: rh }; } -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 flipSouthNorth(p) { - return { x: p.x, y: -p.y, z: -p.z }; +function sampleImageDataBilinear(imgData, w, h, fx, fy) { + const x = Math.max(0, Math.min(w - 1.001, fx)); + const y = Math.max(0, Math.min(h - 1.001, fy)); + const x0 = x | 0; + const y0 = y | 0; + const x1 = Math.min(w - 1, x0 + 1); + const y1 = Math.min(h - 1, y0 + 1); + const tx = x - x0; + const ty = y - y0; + const i00 = (y0 * w + x0) * 4; + const i10 = (y0 * w + x1) * 4; + const i01 = (y1 * w + x0) * 4; + const i11 = (y1 * w + x1) * 4; + const out = [0, 0, 0, 0]; + for (let c = 0; c < 4; c++) { + out[c] = Math.round( + imgData[i00 + c] * (1 - tx) * (1 - ty) + + imgData[i10 + c] * tx * (1 - ty) + + imgData[i01 + c] * (1 - tx) * ty + + imgData[i11 + c] * tx * ty + ); + } + return out; } function inverseRotateY(nx, ny, nz, rotY) { const cosR = Math.cos(rotY); const sinR = Math.sin(rotY); - return flipSouthNorth({ + return { x: nx * cosR - nz * sinR, y: ny, z: nx * sinR + nz * cosR, - }); -} - -function rotateYRaw(x, y, z, rotY) { - return { - x: x * Math.cos(rotY) + z * Math.sin(rotY), - y, - z: -x * Math.sin(rotY) + z * Math.cos(rotY), }; } -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), - }); +/** Hub SVG disk is N-up; negate Y for texture lookup only. */ +function toTextureBody(body) { + return { x: body.x, y: -body.y, z: body.z }; } -function bodyToHubPixel(xb, yb) { +function hubPixelF(tex) { return { - x: Math.round(GLOBE_VIEW.cx + xb * GLOBE_VIEW.r), - y: Math.round(GLOBE_VIEW.cy - yb * GLOBE_VIEW.r), + x: GLOBE_VIEW.cx + tex.x * GLOBE_VIEW.r, + y: GLOBE_VIEW.cy - tex.y * GLOBE_VIEW.r, }; } -function lonLatFromBody(body) { +function mercatorFlatUV(lon, lat, w, h) { return { - lon: Math.atan2(body.x, body.z), - lat: Math.asin(Math.max(-1, Math.min(1, body.y))), + x: ((lon + Math.PI) / (2 * Math.PI)) * (w - 1), + y: (0.5 - lat / Math.PI) * (h - 1), }; } -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 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) { +function isLandPixel(r, g, b, a) { return a > 12 && r > 100 && g < 140 && b < 140; } -/** 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]; - } +function sampleOceanRgba(globe, tex) { + 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 ocean = sampleImageDataBilinear(globe.data, globe.w, globe.h, sx, sy); + if (ocean[3] < 8) { + return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; } - return { data: out, w: EQ_W, h: EQ_H }; + return ocean; } -function viewDirBody(rotY) { - return inverseRotateY(0, 0, 1, rotY); +function sampleOverlayRgba(layer, tex) { + const hp = hubPixelF(tex); + const sx = (hp.x / GLOBE_VIEW.imgW) * (layer.w - 1); + const sy = (hp.y / GLOBE_VIEW.imgH) * (layer.h - 1); + return sampleImageDataBilinear(layer.data, layer.w, layer.h, sx, sy); } -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]; +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; } - return [o[0], o[1], o[2]]; + + 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 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])]; + } + + return null; } function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { @@ -218,11 +191,11 @@ function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { 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 = fn(body); - px[p++] = rgb[0]; - px[p++] = rgb[1]; - px[p++] = rgb[2]; - px[p++] = rgb[3] !== undefined ? rgb[3] : 255; + const rgba = fn(body); + px[p++] = rgba[0]; + px[p++] = rgba[1]; + px[p++] = rgba[2]; + px[p++] = rgba[3] !== undefined ? rgba[3] : 255; } } @@ -241,166 +214,40 @@ 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) { - return sampleOcean(globe, body.x, body.y).concat([255]); + if (body.z <= FRONT_Z) { + return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255]; + } + return sampleOceanRgba(globe, toTextureBody(body)); }); blitLayer(ctx, cx, cy, r, layer); } -function drawContinentsLayer(ctx, cx, cy, r, rotY, landEq) { +function drawGridLayer(ctx, cx, cy, r, rotY, gridOverlay) { const layer = fillDiskRegion(ctx, cx, cy, r, rotY, function (body) { - if (!facingCamera(body, rotY)) { + if (body.z <= FRONT_Z) { 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])) { + const px = sampleOverlayRgba(gridOverlay, toTextureBody(body)); + if (px[3] < 10) { return [0, 0, 0, 0]; } - return [land[0], land[1], land[2], land[3]]; + return px; }); 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_MIN) { - samples.push(null); - continue; +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]; } - 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; + const rgba = sampleLandRgba(continents, mercFlat, body); + if (!rgba) { + return [0, 0, 0, 0]; } - let zSum = 0; - ctx.beginPath(); - ctx.moveTo(run[0].sx, run[0].sy); - for (let i = 1; i < run.length; i++) { - 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]); - } - flushRun(); -} - -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: 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) + ')', - }; -} - -function drawGridOverlay(ctx, rect, rotY) { - const cx = rect.cx; - const cy = rect.cy; - const r = rect.r; - const scale = rect.scale; - const steps = 160; - - ctx.save(); - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.clip(); - - strokeVisibleArcs3D( - ctx, - cx, - cy, - r, - rotY, - function (t) { - return sphereLatLon(-Math.PI + t * 2 * Math.PI, 0); - }, - function (z) { - return parallelStyle(scale, z, true); - }, - steps - ); - - [-0.25, 0.25].forEach(function (latFrac) { - const lat = latFrac * Math.PI; - strokeVisibleArcs3D( - ctx, - cx, - cy, - r, - rotY, - function (t) { - return sphereLatLon(-Math.PI + t * 2 * Math.PI, lat); - }, - function (z) { - return parallelStyle(scale, z, false); - }, - steps - ); + return rgba; }); - - for (let lonDeg = -150; lonDeg <= 150; lonDeg += 30) { - const lon = (lonDeg * Math.PI) / 180; - const isPrime = lonDeg === 0; - strokeVisibleArcs3D( - ctx, - cx, - cy, - r, - rotY, - function (t) { - return sphereLatLon(lon, -Math.PI / 2 + t * Math.PI); - }, - function (z) { - return meridianStyle(isPrime, scale, z); - }, - 96 - ); - } - - ctx.beginPath(); - ctx.arc(cx, cy, Math.max(3, scale * 0.01), 0, Math.PI * 2); - ctx.fillStyle = '#5eead4'; - ctx.fill(); - ctx.strokeStyle = '#0a0e14'; - ctx.lineWidth = 1; - ctx.stroke(); - - ctx.restore(); + blitLayer(ctx, cx, cy, r, layer); } function drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale) { @@ -430,16 +277,19 @@ 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), + gridOverlay: 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 gridOverlayImg; let mercatorFlatImg; try { - [globeImg, continentsImg, mercatorFlatImg] = await Promise.all([ + [globeImg, continentsImg, gridOverlayImg, mercatorFlatImg] = await Promise.all([ loadImage(urls.globe), loadImage(urls.continents), + loadImage(urls.gridOverlay), loadImage(urls.mercatorFlat), ]); } catch (e) { @@ -448,26 +298,12 @@ export async function mountHubGlobe(stage, opts) { return null; } - const globe = { - w: GLOBE_VIEW.imgW, - h: GLOBE_VIEW.imgH, - data: rasterizeImage(globeImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH).data, - }; - const continents = { - w: GLOBE_VIEW.imgW, - 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, - }; - const landEq = bakeLandEquator(continents, mercFlat); + const globe = rasterizeImage(globeImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE); + const continents = rasterizeImage(continentsImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE); + const gridOverlay = rasterizeImage(gridOverlayImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE); + const mercW = (mercatorFlatImg.naturalWidth || 800) * 2; + const mercH = (mercatorFlatImg.naturalHeight || 519) * 2; + const mercFlat = rasterizeImage(mercatorFlatImg, mercW, mercH, 1); let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0; let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S; @@ -503,12 +339,11 @@ export async function mountHubGlobe(stage, opts) { 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 }; ctx.clearRect(0, 0, px, px); drawOceanLayer(ctx, cx, cy, r, rotation, globe); - drawGridOverlay(ctx, drawRect, rotation); - drawContinentsLayer(ctx, cx, cy, r, rotation, landEq); + drawGridLayer(ctx, cx, cy, r, rotation, gridOverlay); + drawLandLayer(ctx, cx, cy, r, rotation, continents, mercFlat); drawRim(ctx, globeImg, cx, cy, r, rim, rect.scale * dpr); ctx.save(); ctx.beginPath(); diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 4f18faa..5ee3da0 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 = '20260606globe4'; + var logoBuild = '20260606globe6'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]');