1
0
mirror of git://f0xx.org/ac/ac-be-hub synced 2026-08-09 20:59:23 +03:00
Files
ac-be-hub/assets/hub-globe.js
Anton Afanasyeu 55f770313a Hub globe3d=6: v4 sphere math with volumetric E-W bands.
Fill latitude strips using hub ellipse ry; keep v4/v5 for comparison.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:20:03 +02:00

932 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hub landing globe — orthographic Canvas2D (?globe3d=N).
* globe3d=0: flat SVG layers (index.php).
* globe3d=1: legacy hybrid ortho front + mercator back + hub SVG grid trace.
* globe3d=2: mercator land attempt + full-sphere grid (superseded).
* globe3d=3: solid ocean, EW parallels only (true latitude rings) — no baked grid bleed.
* globe3d=4: solid ocean, full sphere grid (parallels + meridians).
* globe3d=5: hub ellipse trace (deprecated — breaks under rotation).
* globe3d=6: v4 math + hub ry band fill (latitude strips, limb fade).
*/
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.04;
const LIMB_Z = 0.002;
const RASTER_SCALE = 2;
const AXIS_STEPS = 160;
const SPHERE_AXIS_STEPS = 180;
/** Latitudes from hub grid overlay ellipse centers (meridian x = cx). */
function hubParallelLat(hubCy) {
const texY = (GLOBE_VIEW.cy - hubCy) / GLOBE_VIEW.r;
return Math.asin(Math.max(-1, Math.min(1, texY)));
}
const SPHERE_PARALLELS = [
{ lat: 0, bright: true },
{ lat: hubParallelLat(392), bright: false },
{ lat: hubParallelLat(728), bright: false },
];
/** Longitudes at equator from hub NS meridian artwork (x=620 / x=820). */
const SPHERE_MERIDIANS = [
{ lon: 0, prime: true },
{ lon: Math.atan2((620 - 720) / 322, Math.sqrt(Math.max(0, 1 - Math.pow((620 - 720) / 322, 2)))), prime: false },
{ lon: Math.atan2((820 - 720) / 322, Math.sqrt(Math.max(0, 1 - Math.pow((820 - 720) / 322, 2)))), prime: false },
];
/** hub-logo-fragment-globe-grid-overlay.svg — EW parallels. */
const HUB_EW_PARALLELS = [
{ cx: 720, cy: 560, rx: 318, ry: 11, bright: true },
{ cx: 720, cy: 392, rx: 260, ry: 39, bright: false },
{ cx: 720, cy: 728, rx: 260, ry: 39, bright: false },
];
/** Same SVG — NS meridians (line + quadratic beziers). */
const HUB_NS_MERIDIANS = [
{ prime: true, points: sampleLinePoints(720, 248, 720, 872, AXIS_STEPS) },
{ prime: false, points: sampleQuadPoints(620, 248, 358, 560, 620, 872, AXIS_STEPS) },
{ prime: false, points: sampleQuadPoints(820, 248, 1082, 560, 820, 872, AXIS_STEPS) },
];
function sampleLinePoints(x0, y0, x1, y1, steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
pts.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t });
}
return pts;
}
function sampleQuadPoints(x0, y0, cx, cy, x1, y1, steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const u = 1 - t;
pts.push({
x: u * u * x0 + 2 * u * t * cx + t * t * x1,
y: u * u * y0 + 2 * u * t * cy + t * t * y1,
});
}
return pts;
}
function hubAsset(rel, build) {
const link = document.querySelector('link[href*="hub.css"]');
const base = link ? (link.getAttribute('href') || '').replace(/[^/]*$/, '') : '';
const sep = rel.indexOf('?') >= 0 ? '&' : '?';
return base + rel + sep + 'v=' + build;
}
export function globeCoverRect() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const scale = Math.max(vw / GLOBE_VIEW.imgW, vh / GLOBE_VIEW.imgH);
const drawW = GLOBE_VIEW.imgW * scale;
const drawH = GLOBE_VIEW.imgH * scale;
const offX = (vw - drawW) / 2;
const offY = (vh - drawH) / 2;
const left = offX + (GLOBE_VIEW.cx - GLOBE_VIEW.r) * scale;
const top = offY + (GLOBE_VIEW.cy - GLOBE_VIEW.r) * scale;
const size = GLOBE_VIEW.r * 2 * scale;
return {
left,
top,
width: size,
height: size,
scale,
cx: GLOBE_VIEW.cx * scale + offX,
cy: GLOBE_VIEW.cy * scale + offY,
r: GLOBE_VIEW.r * scale,
rim: GLOBE_VIEW.rim * scale,
};
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image load failed: ' + url));
img.src = url;
});
}
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 = rw;
c.height = rh;
const ctx = c.getContext('2d', { willReadFrequently: true });
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 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 {
x: nx * cosR - nz * sinR,
y: ny,
z: nx * sinR + nz * cosR,
};
}
function bodyToTex(body) {
return { x: body.x, y: -body.y, z: body.z };
}
function bodyToView(body, rotY) {
const cosR = Math.cos(rotY);
const sinR = Math.sin(rotY);
return {
x: body.x * cosR + body.z * sinR,
y: body.y,
z: -body.x * sinR + body.z * cosR,
};
}
/** Hub disk pixel → unit-sphere body (front hemisphere, matches globe3d=0). */
function hubPixelToBody(hpX, hpY) {
const texX = (hpX - GLOBE_VIEW.cx) / GLOBE_VIEW.r;
const texY = (GLOBE_VIEW.cy - hpY) / GLOBE_VIEW.r;
const rr = texX * texX + texY * texY;
if (rr > 1.001) {
return null;
}
return {
x: texX,
y: -texY,
z: Math.sqrt(Math.max(0, 1 - rr)),
};
}
function hubPointsToBodyRing(points) {
const out = [];
for (let i = 0; i < points.length; i++) {
const body = hubPixelToBody(points[i].x, points[i].y);
if (body) {
out.push(body);
}
}
return out;
}
function ellipseBodyRing(ellipse, steps) {
const out = [];
for (let i = 0; i <= steps; i++) {
const a = (i / steps) * Math.PI * 2;
const body = hubPixelToBody(
ellipse.cx + ellipse.rx * Math.cos(a),
ellipse.cy + ellipse.ry * Math.sin(a)
);
if (body) {
out.push(body);
}
}
return out;
}
function parallelStyle(bright, scale, z, limbZ) {
const zCut = limbZ != null ? limbZ : FRONT_Z;
const t = Math.max(0, Math.min(1, (z - zCut) / (0.95 - zCut)));
if (t <= 0) {
return null;
}
const alpha = bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t;
const rgb = bright ? '219,231,246' : '231,236,243';
return {
color: 'rgba(' + rgb + ',' + alpha.toFixed(3) + ')',
width: Math.max(bright ? 3 : 2, scale * (bright ? 0.009 : 0.006)) * (0.5 + 0.5 * t),
};
}
function meridianStyle(prime, scale, z, limbZ) {
const zCut = limbZ != null ? limbZ : FRONT_Z;
const t = Math.max(0, Math.min(1, (z - zCut) / (0.95 - zCut)));
if (t <= 0) {
return null;
}
const alpha = prime ? 0.5 + 0.48 * t : 0.3 + 0.45 * t;
return {
color: 'rgba(238,245,255,' + alpha.toFixed(3) + ')',
width: Math.max(prime ? 3 : 2.5, scale * (prime ? 0.0095 : 0.008)) * (0.45 + 0.55 * t),
};
}
function strokeBodyRing(ctx, cx, cy, r, rotY, bodies, styleAt, limbZ) {
let run = [];
const zCut = limbZ != null ? limbZ : FRONT_Z;
function flush() {
if (run.length < 2) {
run = [];
return;
}
let zSum = 0;
for (let i = 0; i < run.length; i++) {
zSum += run[i].z;
}
const style = styleAt(zSum / run.length);
if (!style) {
run = [];
return;
}
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);
}
ctx.strokeStyle = style.color;
ctx.lineWidth = style.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
run = [];
}
for (let i = 0; i < bodies.length; i++) {
const view = bodyToView(bodies[i], rotY);
if (view.z <= zCut) {
flush();
continue;
}
run.push({
sx: cx + view.x * r,
sy: cy - view.y * r,
z: view.z,
});
}
flush();
}
function parallelBodyRingAtLat(lat, steps) {
const out = [];
const cosLat = Math.cos(lat);
const sinLat = Math.sin(lat);
for (let i = 0; i <= steps; i++) {
const lon = -Math.PI + (2 * Math.PI * i) / steps;
out.push({
x: cosLat * Math.sin(lon),
y: sinLat,
z: cosLat * Math.cos(lon),
});
}
return out;
}
function meridianBodyRingAtLon(lon, steps) {
const out = [];
for (let i = 0; i <= steps; i++) {
const lat = -Math.PI / 2 + (Math.PI * i) / steps;
const cosLat = Math.cos(lat);
out.push({
x: cosLat * Math.sin(lon),
y: Math.sin(lat),
z: cosLat * Math.cos(lon),
});
}
return out;
}
function parallelBandHalfWidth(par) {
const hubRy = par.bright ? HUB_EW_PARALLELS[0].ry : HUB_EW_PARALLELS[1].ry;
return hubRy / GLOBE_VIEW.r;
}
/** Filled EW band between two latitude rings (hub SVG ry → sphere half-width). */
function fillParallelBand(ctx, cx, cy, r, rotY, par, layoutScale) {
const lat = par.lat;
const halfW = parallelBandHalfWidth(par);
const latLo = Math.max(-Math.PI / 2 + 0.02, lat - halfW);
const latHi = Math.min(Math.PI / 2 - 0.02, lat + halfW);
const inner = parallelBodyRingAtLat(latLo, SPHERE_AXIS_STEPS);
const outer = parallelBodyRingAtLat(latHi, SPHERE_AXIS_STEPS);
const n = Math.min(inner.length, outer.length);
const rgb = par.bright ? '219,231,246' : '231,236,243';
for (let i = 0; i < n - 1; i++) {
const views = [
bodyToView(inner[i], rotY),
bodyToView(inner[i + 1], rotY),
bodyToView(outer[i + 1], rotY),
bodyToView(outer[i], rotY),
];
const zAvg = (views[0].z + views[1].z + views[2].z + views[3].z) / 4;
if (zAvg <= LIMB_Z) {
continue;
}
const zMin = Math.min(views[0].z, views[1].z, views[2].z, views[3].z);
const t = Math.max(0, Math.min(1, (zAvg - LIMB_Z) / (0.95 - LIMB_Z)));
const limbFade = Math.min(1, Math.max(0, (zMin - LIMB_Z) / 0.12));
const alpha = (par.bright ? 0.2 + 0.55 * t : 0.1 + 0.38 * t) * limbFade;
if (alpha <= 0.008) {
continue;
}
ctx.beginPath();
ctx.moveTo(cx + views[0].x * r, cy - views[0].y * r);
ctx.lineTo(cx + views[1].x * r, cy - views[1].y * r);
ctx.lineTo(cx + views[2].x * r, cy - views[2].y * r);
ctx.lineTo(cx + views[3].x * r, cy - views[3].y * r);
ctx.closePath();
ctx.fillStyle = 'rgba(' + rgb + ',' + alpha.toFixed(3) + ')';
ctx.fill();
}
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
parallelBodyRingAtLat(lat, SPHERE_AXIS_STEPS),
function (z) {
const style = parallelStyle(par.bright, layoutScale, z, LIMB_Z);
if (!style) {
return null;
}
return {
color: style.color,
width: style.width * (par.bright ? 1.15 : 1.05),
};
},
LIMB_Z
);
}
/** globe3d=6 — v4 sphere model with volumetric EW bands. */
function drawSphereGridBanded(ctx, cx, cy, r, rotY, layoutScale, opts) {
const parallelsOnly = !!(opts && opts.parallelsOnly);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
SPHERE_PARALLELS.forEach(function (par) {
fillParallelBand(ctx, cx, cy, r, rotY, par, layoutScale);
});
if (!parallelsOnly) {
SPHERE_MERIDIANS.forEach(function (mer) {
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS),
function (z) {
return meridianStyle(mer.prime, layoutScale, z, LIMB_Z);
},
LIMB_Z
);
});
}
ctx.restore();
}
/** Full-sphere grid rings (globe3d>=4). */
function drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, opts) {
const parallelsOnly = !!(opts && opts.parallelsOnly);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
SPHERE_PARALLELS.forEach(function (par) {
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS),
function (z) {
return parallelStyle(par.bright, layoutScale, z, LIMB_Z);
},
LIMB_Z
);
});
if (!parallelsOnly) {
SPHERE_MERIDIANS.forEach(function (mer) {
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS),
function (z) {
return meridianStyle(mer.prime, layoutScale, z, LIMB_Z);
},
LIMB_Z
);
});
}
ctx.restore();
}
/** Hub SVG ellipses traced on sphere (globe3d=5) — matches static art at yaw 0. */
function drawHubParallelGrid(ctx, cx, cy, r, rotY, layoutScale) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
HUB_EW_PARALLELS.forEach(function (ellipse) {
strokeBodyRing(ctx, cx, cy, r, rotY, ellipseBodyRing(ellipse, AXIS_STEPS), function (z) {
return parallelStyle(ellipse.bright, layoutScale, z, LIMB_Z);
}, LIMB_Z);
});
ctx.restore();
}
/** Hub SVG grid: EW ellipses + NS meridian paths (globe3d=1). */
function drawHubGrid(ctx, cx, cy, r, rotY, layoutScale, opts) {
const parallelsOnly = !!(opts && opts.parallelsOnly);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
HUB_EW_PARALLELS.forEach(function (ellipse) {
strokeBodyRing(ctx, cx, cy, r, rotY, ellipseBodyRing(ellipse, AXIS_STEPS), function (z) {
return parallelStyle(ellipse.bright, layoutScale, z);
});
});
if (!parallelsOnly) {
HUB_NS_MERIDIANS.forEach(function (mer) {
strokeBodyRing(ctx, cx, cy, r, rotY, hubPointsToBodyRing(mer.points), function (z) {
return meridianStyle(mer.prime, layoutScale, z);
});
});
}
ctx.restore();
}
function drawGlobeGrid(ctx, cx, cy, r, rotY, layoutScale, renderVersion) {
if (renderVersion === 3) {
drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: true });
return;
}
if (renderVersion === 4) {
drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
return;
}
if (renderVersion === 5) {
drawHubParallelGrid(ctx, cx, cy, r, rotY, layoutScale);
return;
}
if (renderVersion === 6) {
drawSphereGridBanded(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
return;
}
if (renderVersion >= 2) {
drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
return;
}
drawHubGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
}
function drawOceanRim(ctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion) {
if (renderVersion >= 3) {
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();
const g = ctx.createRadialGradient(cx, cy, r, cx, cy, rim);
g.addColorStop(0, 'rgba(28,48,74,0.98)');
g.addColorStop(1, 'rgba(14,24,38,0.92)');
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
return;
}
drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale);
}
function sampleHub(layer, tex) {
const hp = {
x: GLOBE_VIEW.cx + tex.x * GLOBE_VIEW.r,
y: GLOBE_VIEW.cy - tex.y * GLOBE_VIEW.r,
};
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 texLonLat(tex) {
return {
lon: Math.atan2(tex.x, tex.z),
lat: Math.asin(Math.max(-1, Math.min(1, tex.y))),
};
}
function sampleMercFlat(mercFlat, lon, lat) {
const u = ((lon + Math.PI) / (2 * Math.PI)) * (mercFlat.w - 1);
const v = (0.5 - lat / Math.PI) * (mercFlat.h - 1);
return sampleImageDataBilinear(mercFlat.data, mercFlat.w, mercFlat.h, u, v);
}
function isLandPixel(r, g, b, a) {
return a > 10 && r > 100 && g < 140 && b < 140;
}
/** Light hub grid strokes baked into hub-logo-fragment-globe.svg (vector grid drawn separately). */
function isGridPixel(r, g, b, a) {
if (a < 12) {
return false;
}
const lum = r * 0.299 + g * 0.587 + b * 0.114;
if (lum < 125) {
return false;
}
if (g < 155 || b < 155) {
return false;
}
return Math.max(r, g, b) - Math.min(r, g, b) < 85;
}
const OCEAN_SOLID = [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255];
function sampleOceanSolid() {
return OCEAN_SOLID;
}
function sampleOceanClean(globe, tex) {
if (tex.z <= FRONT_Z) {
return OCEAN_SOLID;
}
const s = sampleHub(globe, tex);
if (s[3] > 8 && !isGridPixel(s[0], s[1], s[2], s[3])) {
return [s[0], s[1], s[2], 255];
}
return OCEAN_SOLID;
}
function sampleLandRgbaLegacy(continents, mercFlat, tex) {
if (tex.z > FRONT_Z) {
const ortho = sampleHub(continents, tex);
if (ortho[3] > 6) {
return ortho;
}
return null;
}
if (tex.z > -FRONT_Z) {
return null;
}
const ll = texLonLat(tex);
const flat = sampleMercFlat(mercFlat, ll.lon, ll.lat);
if (isLandPixel(flat[0], flat[1], flat[2], flat[3])) {
return [flat[0], flat[1], flat[2], Math.min(255, flat[3])];
}
return null;
}
/** globe3d>=2: mercator-flat on entire visible hemisphere (z>0). */
function sampleLandRgbaMercator(mercFlat, tex) {
if (tex.z <= LIMB_Z) {
return null;
}
const ll = texLonLat(tex);
const flat = sampleMercFlat(mercFlat, ll.lon, ll.lat);
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) {
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 null;
}
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;
if (ox * ox + oy * oy > 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 tex = bodyToTex(inverseRotateY(nx, ny, nz, rotY));
const rgba = fn(tex);
px[p++] = rgba[0];
px[p++] = rgba[1];
px[p++] = rgba[2];
px[p++] = rgba[3] !== undefined ? rgba[3] : 255;
}
}
return { out, x0, y0 };
}
function blitLayer(ctx, cx, cy, r, layer) {
if (!layer) {
return;
}
const { out, x0, y0 } = layer;
const w = out.width;
const h = out.height;
const src = out.data;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
const existing = ctx.getImageData(x0, y0, w, h);
const dst = existing.data;
for (let i = 0; i < src.length; i += 4) {
const sa = src[i + 3];
if (sa === 0) {
continue;
}
if (sa === 255) {
dst[i] = src[i];
dst[i + 1] = src[i + 1];
dst[i + 2] = src[i + 2];
dst[i + 3] = 255;
continue;
}
const a = sa / 255;
dst[i] = Math.round(src[i] * a + dst[i] * (1 - a));
dst[i + 1] = Math.round(src[i + 1] * a + dst[i + 1] * (1 - a));
dst[i + 2] = Math.round(src[i + 2] * a + dst[i + 2] * (1 - a));
dst[i + 3] = 255;
}
ctx.putImageData(existing, x0, y0);
ctx.restore();
}
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(globeImg, 0, 0, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, dx, dy, drawW, drawH);
ctx.restore();
}
export async function mountHubGlobe(stage, opts) {
const build = opts.build || 'earth3d';
const reducedMotion = !!opts.reducedMotion;
const renderVersion = Math.max(1, parseInt(opts.renderVersion, 10) || 1);
const canvas = document.createElement('canvas');
canvas.className = 'hub-globe-canvas';
canvas.setAttribute('aria-hidden', 'true');
stage.appendChild(canvas);
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, mercatorFlatImg] = await Promise.all([
loadImage(urls.globe),
loadImage(urls.continents),
loadImage(urls.mercatorFlat),
]);
} catch (e) {
console.warn('HubGlobe: asset load failed', e);
canvas.remove();
return null;
}
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 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;
let dragging = false;
let lastPointerX = 0;
let lastPointerTime = 0;
let raf = 0;
let lastFrame = performance.now();
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const resize = () => {
const rect = globeCoverRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const px = Math.max(64, Math.floor(rect.width * dpr));
canvas.style.left = rect.left + 'px';
canvas.style.top = rect.top + 'px';
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
canvas.width = px;
canvas.height = px;
return { rect, dpr, px };
};
let layout = resize();
window.addEventListener('resize', () => {
layout = resize();
});
const render = () => {
const { rect, dpr, px } = layout;
const cx = (rect.cx - rect.left) * dpr;
const cy = (rect.cy - rect.top) * dpr;
const r = rect.r * dpr;
const rim = rect.rim * dpr;
const layoutScale = rect.scale * dpr;
ctx.clearRect(0, 0, px, px);
blitLayer(
ctx,
cx,
cy,
r,
fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) {
if (renderVersion >= 3) {
return sampleOceanSolid();
}
return sampleOceanClean(globe, tex);
})
);
if (renderVersion === 1) {
blitLayer(
ctx,
cx,
cy,
r,
fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) {
const l = sampleLandRgbaLegacy(continents, mercFlat, tex);
return l || [0, 0, 0, 0];
})
);
} else if (renderVersion === 2) {
blitLayer(
ctx,
cx,
cy,
r,
fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) {
const l = sampleLandRgbaMercator(mercFlat, tex);
return l || [0, 0, 0, 0];
})
);
}
drawGlobeGrid(ctx, cx, cy, r, rotation, layoutScale, renderVersion);
drawOceanRim(ctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(10,14,20,0.55)';
ctx.lineWidth = Math.max(2, layoutScale * 0.006);
ctx.stroke();
ctx.restore();
};
const onPointerDown = (ev) => {
if (ev.button !== 0) return;
dragging = true;
lastPointerX = ev.clientX;
lastPointerTime = performance.now();
canvas.setPointerCapture(ev.pointerId);
canvas.classList.add('is-dragging');
ev.preventDefault();
};
const onPointerMove = (ev) => {
if (!dragging) return;
const now = performance.now();
const dx = ev.clientX - lastPointerX;
rotation += dx * DRAG_ROTATION_PER_PX;
if (now > lastPointerTime) {
const inst = (dx * DRAG_ROTATION_PER_PX) / ((now - lastPointerTime) / 1000);
angularVelocity = Math.max(-2.5, Math.min(2.5, inst));
}
lastPointerX = ev.clientX;
lastPointerTime = now;
ev.preventDefault();
};
const endDrag = (ev) => {
if (!dragging) return;
dragging = false;
canvas.classList.remove('is-dragging');
try {
canvas.releasePointerCapture(ev.pointerId);
} catch (ignored) {}
};
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = Math.min(0.05, (now - lastFrame) / 1000);
lastFrame = now;
if (!dragging) {
if (!reducedMotion) {
const target = AUTO_SPIN_RAD_S;
angularVelocity += (target - angularVelocity) * 1.2 * dt;
if (Math.abs(angularVelocity - target) < 0.004) angularVelocity = target;
} else {
angularVelocity *= Math.exp(-4 * dt);
}
rotation += angularVelocity * dt;
}
render();
};
raf = requestAnimationFrame(tick);
stage.dataset.globe3d = String(renderVersion);
stage.dataset.globeAxis =
renderVersion >= 6
? 'ew-sphere-band'
: renderVersion >= 5
? 'ew-hub-ellipse'
: renderVersion >= 3
? 'ew-sphere'
: renderVersion >= 2
? 'ew-ns-sphere'
: 'ew-ns-grid';
return {
destroy() {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
canvas.remove();
delete stage.dataset.globe3d;
delete stage.dataset.globeAxis;
},
};
}