1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:37:52 +03:00

sync globe

This commit is contained in:
Anton Afanasyeu
2026-06-06 17:17:49 +02:00
parent 097df9a22e
commit bcec787bf3
3 changed files with 230 additions and 165 deletions

View File

@@ -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();