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 c54919189c Hub globe v2: mercator wrap, GPU path, landing layout updates.
Refine 3D globe rendering, layer CSS, landing page wiring and TOC UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 22:15:46 +02:00

2288 lines
67 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 + latitude band fill (continuous arc paths).
* globe3d=7: v4 math + wide centerline stroke bands (no fill quads).
* globe3d=8: hub SVG ellipse trace (breaks under rotation — deprecated).
* globe3d=9: v4 sphere grid only (thin EW + NS) — proper rotation baseline.
* globe3d=10: v4 visible-arc → screen ellipse (hub ry), disk-clipped.
* globe3d=11: v10 grid + geographic mercator-flat land (360° U-wrap).
* ?latf=1.25&lngf=1&zy=1&zx=1&pp=0&pe=0 — land plate zoom + pole/limb lens pinch.
* ?ax=5 — auto-spin speed factor (default 5 for landing hub).
* ?cfa / ?cba / ?cbr / ?cbg / ?cbb / ?ceps — continent fill, border, coast (globe3d=11).
* ?slx / ?sly / ?slx2 / ?sly2 / ?sld / ?sll / ?sli / ?slhi / ?slsh — sunlight (globe3d=11).
* ?fogi / ?fogr / ?fogin — atmospheric fog around disk (globe3d=11).
* ?globeFps / ?globeDpr — auto-spin FPS cap + max DPR (globe3d=11 perf).
* ?globeGpu=0 — force CPU land pass; ?globeProfile=1 — console render timings.
*/
export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 };
const AUTO_SPIN_RAD_S = 0.035;
/** Landing hub default auto-spin multiplier (?ax=). */
const GLOBE_SPIN_FACTOR_DEFAULT = 5;
const DRAG_ROTATION_PER_PX = 0.004;
/** globe3d=11 perf — cap DPR + auto-spin FPS (?globeDpr / ?globeFps). */
const GLOBE_DPR_MAX = 1;
const GLOBE_SPIN_FPS = 1;
const OCEAN_FALLBACK = { r: 22, g: 38, b: 60 };
/** Interior land fill — coast/body edge use base + ?cbr / ?cbg / ?cbb / ?cba. */
const CONTINENT_FILL_RGB = { r: 148, g: 172, b: 192 };
const FRONT_Z = 0.04;
const LIMB_Z = 0.002;
/** hub-logo-continents inner layer fit (continents-meta.json / extract_red_polygons.py). */
const CONTINENT_FLAT_W = 800;
const CONTINENT_FLAT_H = 519;
const CONTINENT_INNER_TX = 223.66088631984587;
const CONTINENT_INNER_TY = 238.0;
const CONTINENT_INNER_SX = 1.2408477842003853;
const CONTINENT_INNER_SY = 1.2408477842003853;
/** Map plate center longitude (extract_red_polygons.py CENTER_LON). */
const MERCATOR_LON_CENTER = (12 * Math.PI) / 180;
/** Default land-plate zoom (?latf / ?lngf / ?zy / ?zx on globe3d=11). */
const LAND_ZOOM_DEFAULT = { latf: 1.25, lngf: 1, zy: 1, zx: 1, pp: 0.2, pe: 0 };
/** globe3d=11 continent paint (?cfa / ?cba / ?cbr / ?cbg / ?cbb / ?ceps). */
const CONTINENT_STYLE_DEFAULT = {
fillAlpha: 92,
borderAlpha: 248,
borderBoostR: 30,
borderBoostG: 22,
borderBoostB: 12,
coastEps: 0.0045,
};
function resolveContinentStyle(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
return {
fillAlpha: clampZoomNum(r.fillAlpha, 0, 255, CONTINENT_STYLE_DEFAULT.fillAlpha),
borderAlpha: clampZoomNum(r.borderAlpha, 0, 255, CONTINENT_STYLE_DEFAULT.borderAlpha),
borderBoostR: clampZoomNum(r.borderBoostR, 0, 96, CONTINENT_STYLE_DEFAULT.borderBoostR),
borderBoostG: clampZoomNum(r.borderBoostG, 0, 96, CONTINENT_STYLE_DEFAULT.borderBoostG),
borderBoostB: clampZoomNum(r.borderBoostB, 0, 96, CONTINENT_STYLE_DEFAULT.borderBoostB),
coastEps: clampZoomNum(r.coastEps, 0.001, 0.05, CONTINENT_STYLE_DEFAULT.coastEps),
};
}
/** globe3d=11 sunlight (?slx / ?sly / ?slx2 / ?sly2 / ?sld / ?sll / ?sli / ?slhi / ?slsh). */
const SUNLIGHT_STYLE_DEFAULT = {
lx: -0.92,
ly: -0.88,
lx2: 0.95,
ly2: 0.92,
dir: null,
span: 1.85,
intensity: 1,
highlight: 0.75,
shadow: 1.25,
};
function resolveSunlightStyle(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
const hasDir = typeof r.dir === 'number' && isFinite(r.dir);
return {
lx: clampZoomNum(r.lx, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.lx),
ly: clampZoomNum(r.ly, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.ly),
lx2: hasDir ? null : clampZoomNum(r.lx2, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.lx2),
ly2: hasDir ? null : clampZoomNum(r.ly2, -1.5, 1.5, SUNLIGHT_STYLE_DEFAULT.ly2),
dir: hasDir ? r.dir : null,
span: clampZoomNum(r.span, 0.3, 4, SUNLIGHT_STYLE_DEFAULT.span),
intensity: clampZoomNum(r.intensity, 0, 3, SUNLIGHT_STYLE_DEFAULT.intensity),
highlight: clampZoomNum(r.highlight, 0, 1, SUNLIGHT_STYLE_DEFAULT.highlight),
shadow: clampZoomNum(r.shadow, 0, 1, SUNLIGHT_STYLE_DEFAULT.shadow),
};
}
/** globe3d=11 rim fog (?fogi / ?fogr / ?fogin). */
const FOG_STYLE_DEFAULT = {
intensity: 2.7,
outer: 0.15,
inner: 0.7,
};
function resolveFogStyle(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
return {
intensity: clampZoomNum(r.intensity, 0, 3, FOG_STYLE_DEFAULT.intensity),
outer: clampZoomNum(r.outer, 0, 2, FOG_STYLE_DEFAULT.outer),
inner: clampZoomNum(r.inner, 0.7, 1.05, FOG_STYLE_DEFAULT.inner),
};
}
function fogPadCss(rect, fogStyle) {
const fog = fogStyle || resolveFogStyle(null);
if (fog.intensity <= 0 || fog.outer <= 0) {
return 0;
}
return rect.r * fog.outer;
}
function sunlightGradientEnds(style, cx, cy, r) {
const x1 = cx + style.lx * r;
const y1 = cy + style.ly * r;
let x2;
let y2;
if (style.dir != null) {
const rad = (style.dir * Math.PI) / 180;
x2 = x1 + Math.cos(rad) * style.span * r;
y2 = y1 + Math.sin(rad) * style.span * r;
} else {
x2 = cx + style.lx2 * r;
y2 = cy + style.ly2 * r;
}
return { x1: x1, y1: y1, x2: x2, y2: y2 };
}
function clampZoomNum(v, min, max, fallback) {
if (typeof v !== 'number' || !isFinite(v)) {
return fallback;
}
return Math.max(min, Math.min(max, v));
}
function resolveLandZoom(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
return {
latf: clampZoomNum(r.latf, 0.05, 4, LAND_ZOOM_DEFAULT.latf),
lngf: clampZoomNum(r.lngf, 0.05, 4, LAND_ZOOM_DEFAULT.lngf),
zy: clampZoomNum(r.zy, 0.05, 4, LAND_ZOOM_DEFAULT.zy),
zx: clampZoomNum(r.zx, 0.05, 4, LAND_ZOOM_DEFAULT.zx),
pp: clampZoomNum(r.pp, 0, 0.95, LAND_ZOOM_DEFAULT.pp),
pe: clampZoomNum(r.pe, 0, 0.95, LAND_ZOOM_DEFAULT.pe),
};
}
/** URL ?ax=N — auto-spin speed multiplier (landing default 5). */
function resolveSpinFactor(raw) {
if (typeof raw !== 'number' || !isFinite(raw)) {
return GLOBE_SPIN_FACTOR_DEFAULT;
}
return Math.max(0.05, Math.min(8, raw));
}
function resolveGlobePerf(raw) {
const r = raw && typeof raw === 'object' ? raw : {};
return {
dprMax: clampZoomNum(r.dprMax, 0.75, 2, GLOBE_DPR_MAX),
spinFps: clampZoomNum(r.spinFps, 1, 60, GLOBE_SPIN_FPS),
useGpu: r.useGpu !== false,
profile: !!r.profile,
};
}
/**
* Land plate sample remap (globe3d=11 URL tunables):
* latf/lngf — linear zoom; zy/zx — equator/prime-meridian mag (1 = uniform).
* pp — pole lens pinch (0 = off): pulls N/S sample toward equator near poles.
* pe — disk limb pinch (0 = off): uses radial distance on ortho disk, not geo lon.
*/
function landZoomLonLat(lon, lat, zoom, tex) {
const latT = Math.min(1, Math.abs(lat) / (Math.PI / 2));
const lonT = Math.min(1, Math.abs(lon) / Math.PI);
const rimT = Math.min(1, Math.sqrt(tex.x * tex.x + tex.y * tex.y));
const latMul = zoom.latf * (zoom.zy * (1 - latT * latT) + latT * latT);
const lonMul = zoom.lngf * (zoom.zx * (1 - lonT * lonT) + lonT * lonT);
const poleLens = 1 - zoom.pp * latT * latT;
const edgeLens = 1 - zoom.pe * rimT * rimT;
return {
lon: wrapLon(lon * lonMul * edgeLens + MERCATOR_LON_CENTER),
lat: Math.max(-1.52, Math.min(1.52, lat * latMul * poleLens)),
};
}
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 meridian artwork (globe3d=1 static trace; beziers break under Y spin). */
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) },
];
/** globe3d=11 — ±π/4 face zones; antipodal pairs so 3 stay on the front cap at any rotY. */
const HUB_MERIDIAN_BANDS = [
{ lon: -Math.PI / 4, prime: false },
{ lon: 0, prime: true },
{ lon: Math.PI / 4, prime: false },
{ lon: (3 * Math.PI) / 4, prime: false },
{ lon: Math.PI, prime: false },
{ lon: (-3 * Math.PI) / 4, prime: false },
];
/** Include limb/pole (z=0); still cull back hemisphere (z < 0). */
const MERIDIAN_VIEW_Z = -0.0001;
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 sampleImageDataBilinearWrapU(imgData, w, h, fx, fy) {
const y = Math.max(0, Math.min(h - 1.001, fy));
let x = fx % w;
if (x < 0) {
x += w;
}
const x0 = x | 0;
const tx = x - x0;
const x1 = (x0 + 1) % w;
const y0 = y | 0;
const y1 = Math.min(h - 1, y0 + 1);
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 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;
out.push(
hubPixelToBody(ellipse.cx + ellipse.rx * Math.cos(a), ellipse.cy + ellipse.ry * Math.sin(a))
);
}
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 continentBorderRgba(cs, base) {
const b = base || CONTINENT_FILL_RGB;
return {
r: Math.min(255, b.r + cs.borderBoostR),
g: Math.min(255, b.g + cs.borderBoostG),
b: Math.min(255, b.b + cs.borderBoostB),
a: cs.borderAlpha / 255,
};
}
/** globe3d=11 — shared thin grid stroke (parallels + meridians). */
function gridLineStrokeStyle(bright, layoutScale, t) {
const rgb = bright ? '219,231,246' : '231,236,243';
const alpha = bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t;
const lineW = Math.max(bright ? 2.5 : 2, layoutScale * 0.0075) * (0.55 + 0.45 * t);
return { rgb: rgb, alpha: alpha, lineW: lineW };
}
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),
};
}
/** globe3d=11 — thin meridian matching EW parallel stroke. */
function strokeMeridianHubLine(ctx, cx, cy, r, rotY, mer, layoutScale) {
const ring = meridianBodyRingAtLon(mer.lon, SPHERE_AXIS_STEPS);
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
ring,
function (z) {
const zVis = Math.max(0, z);
const t = Math.max(0.15, Math.min(1, zVis / 0.95));
const s = gridLineStrokeStyle(mer.prime, layoutScale, t);
return {
color: 'rgba(' + s.rgb + ',' + s.alpha.toFixed(3) + ')',
width: s.lineW,
};
},
MERIDIAN_VIEW_Z
);
}
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++) {
if (!bodies[i]) {
flush();
continue;
}
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;
}
function parallelBandStyle(par, z, bandPx, layer) {
const t = Math.max(0, Math.min(1, (z - LIMB_Z) / (0.95 - LIMB_Z)));
if (t <= 0) {
return null;
}
const rgb = par.bright ? '219,231,246' : '231,236,243';
if (layer === 'halo') {
return {
color: 'rgba(' + rgb + ',' + (0.1 + 0.2 * t).toFixed(3) + ')',
width: bandPx * 1.08,
};
}
return {
color: 'rgba(' + rgb + ',' + (par.bright ? 0.4 + 0.52 * t : 0.24 + 0.4 * t).toFixed(3) + ')',
width: bandPx * (0.58 + 0.42 * t),
};
}
/** One filled ribbon per visible arc — no per-longitude quads (avoids comb artifacts). */
function fillParallelBandPath(ctx, cx, cy, r, rotY, par) {
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 outer = parallelBodyRingAtLat(latHi, SPHERE_AXIS_STEPS);
const inner = parallelBodyRingAtLat(latLo, SPHERE_AXIS_STEPS);
const n = Math.min(outer.length, inner.length);
const rgb = par.bright ? '219,231,246' : '231,236,243';
let runO = [];
let runI = [];
function flushFill() {
if (runO.length < 2) {
runO = [];
runI = [];
return;
}
let zSum = 0;
for (let k = 0; k < runO.length; k++) {
zSum += runO[k].z;
}
const zAvg = zSum / runO.length;
const t = Math.max(0, Math.min(1, (zAvg - LIMB_Z) / (0.95 - LIMB_Z)));
if (t <= 0) {
runO = [];
runI = [];
return;
}
const alpha = (par.bright ? 0.18 + 0.52 * t : 0.1 + 0.36 * t).toFixed(3);
ctx.beginPath();
ctx.moveTo(runO[0].sx, runO[0].sy);
for (let k = 1; k < runO.length; k++) {
ctx.lineTo(runO[k].sx, runO[k].sy);
}
for (let k = runI.length - 1; k >= 0; k--) {
ctx.lineTo(runI[k].sx, runI[k].sy);
}
ctx.closePath();
ctx.fillStyle = 'rgba(' + rgb + ',' + alpha + ')';
ctx.fill();
runO = [];
runI = [];
}
for (let i = 0; i < n; i++) {
const vo = bodyToView(outer[i], rotY);
const vi = bodyToView(inner[i], rotY);
if (vo.z <= LIMB_Z || vi.z <= LIMB_Z) {
flushFill();
continue;
}
runO.push({ sx: cx + vo.x * r, sy: cy - vo.y * r, z: vo.z });
runI.push({ sx: cx + vi.x * r, sy: cy - vi.y * r, z: vi.z });
}
flushFill();
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
parallelBodyRingAtLat(lat, SPHERE_AXIS_STEPS),
function (z) {
const style = parallelStyle(par.bright, 1, z, LIMB_Z);
if (!style) {
return null;
}
return {
color: style.color,
width: Math.max(style.width, (par.bright ? 1.8 : 1.4)),
};
},
LIMB_Z
);
}
/** Wide dual stroke on centerline — globe3d=7. */
function strokeParallelBandWide(ctx, cx, cy, r, rotY, par, layoutScale) {
const halfW = parallelBandHalfWidth(par);
const bandPx = Math.max(par.bright ? 6 : 10, 2 * halfW * r);
const ring = parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS);
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
ring,
function (z) {
return parallelBandStyle(par, z, bandPx, 'halo');
},
LIMB_Z
);
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
ring,
function (z) {
return parallelBandStyle(par, z, bandPx, 'core');
},
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) {
fillParallelBandPath(ctx, cx, cy, r, rotY, par);
});
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 ellipse centerline on sphere, wide stroke — stays elliptical under Y rotation (globe3d=8). */
function strokeHubEllipseParallel(ctx, cx, cy, r, rotY, ellipse, layoutScale) {
const scale = r / GLOBE_VIEW.r;
const bandPx = Math.max(ellipse.bright ? 5 : 8, 2 * ellipse.ry * scale);
const par = { bright: ellipse.bright };
const ring = ellipseBodyRing(ellipse, AXIS_STEPS);
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
ring,
function (z) {
return parallelBandStyle(par, z, bandPx, 'halo');
},
LIMB_Z
);
strokeBodyRing(
ctx,
cx,
cy,
r,
rotY,
ring,
function (z) {
return parallelBandStyle(par, z, bandPx, 'core');
},
LIMB_Z
);
}
/** globe3d=8 — hub EW ellipses on sphere; v4 NS meridians. */
function drawHubEllipseGridRotated(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) {
strokeHubEllipseParallel(ctx, cx, cy, r, rotY, ellipse, layoutScale);
});
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();
}
function hubRyForParallel(par) {
if (par.bright) {
return HUB_EW_PARALLELS[0].ry;
}
return par.lat > 0 ? HUB_EW_PARALLELS[1].ry : HUB_EW_PARALLELS[2].ry;
}
/** v4 visible arc bounds → 2D ellipse (curved ends via disk clip). */
function drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par) {
let xMin = Infinity;
let xMax = -Infinity;
let zSum = 0;
let n = 0;
const ring = parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS);
for (let i = 0; i < ring.length; i++) {
const view = bodyToView(ring[i], rotY);
if (view.z <= LIMB_Z) {
continue;
}
xMin = Math.min(xMin, view.x);
xMax = Math.max(xMax, view.x);
zSum += view.z;
n++;
}
if (n < 2 || xMax <= xMin) {
return;
}
const t = Math.max(0, Math.min(1, (zSum / n - LIMB_Z) / (0.95 - LIMB_Z)));
const rx = ((xMax - xMin) / 2) * r;
const ry = hubRyForParallel(par) * (r / GLOBE_VIEW.r);
if (rx < 0.5 || ry < 0.5) {
return;
}
const ecx = cx + ((xMin + xMax) / 2) * r;
const ecy = cy - Math.sin(par.lat) * r;
const layoutScale = r / GLOBE_VIEW.r;
const stroke = gridLineStrokeStyle(par.bright, layoutScale, t);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.beginPath();
ctx.ellipse(ecx, ecy, rx, ry, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(' + stroke.rgb + ',' + stroke.alpha.toFixed(3) + ')';
ctx.lineWidth = stroke.lineW;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
ctx.restore();
}
/** @deprecated screen-Y strips — flat rectangles; use drawParallelScreenEllipse. */
function fillParallelScreenStrip(ctx, cx, cy, r, rotY, par) {
const halfPx = (par.bright ? HUB_EW_PARALLELS[0].ry : HUB_EW_PARALLELS[1].ry) * (r / GLOBE_VIEW.r);
const ring = parallelBodyRingAtLat(par.lat, SPHERE_AXIS_STEPS);
const rgb = par.bright ? '219,231,246' : '231,236,243';
let run = [];
function flushFill() {
if (run.length < 2) {
run = [];
return;
}
let zSum = 0;
for (let k = 0; k < run.length; k++) {
zSum += run[k].z;
}
const zAvg = zSum / run.length;
const t = Math.max(0, Math.min(1, (zAvg - LIMB_Z) / (0.95 - LIMB_Z)));
if (t <= 0) {
run = [];
return;
}
const alpha = (par.bright ? 0.16 + 0.48 * t : 0.09 + 0.34 * t).toFixed(3);
ctx.beginPath();
for (let k = 0; k < run.length; k++) {
ctx.lineTo(run[k].sx, run[k].sy - halfPx);
}
for (let k = run.length - 1; k >= 0; k--) {
ctx.lineTo(run[k].sx, run[k].sy + halfPx);
}
ctx.closePath();
ctx.fillStyle = 'rgba(' + rgb + ',' + alpha + ')';
ctx.fill();
run = [];
}
for (let i = 0; i < ring.length; i++) {
const view = bodyToView(ring[i], rotY);
if (view.z <= LIMB_Z) {
flushFill();
continue;
}
run.push({
sx: cx + view.x * r,
sy: cy - view.y * r,
z: view.z,
});
}
flushFill();
}
/** globe3d=10 — v4 EW screen strips + v4 meridians. */
function drawSphereGridScreenStrip(ctx, cx, cy, r, rotY, layoutScale) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
SPHERE_PARALLELS.forEach(function (par) {
drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par);
});
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();
}
/** globe3d=11 — v10 EW ellipses + hub-x great-circle meridians (pole to pole). */
function drawSphereGridV11(ctx, cx, cy, r, rotY, layoutScale) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
SPHERE_PARALLELS.forEach(function (par) {
drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par);
});
HUB_MERIDIAN_BANDS.forEach(function (mer) {
strokeMeridianHubLine(ctx, cx, cy, r, rotY, mer, layoutScale);
});
ctx.restore();
}
/** globe3d=7 — v4 sphere model, wide stroke bands (no path fill). */
function drawSphereGridWideStroke(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) {
strokeParallelBandWide(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 === 7) {
drawSphereGridWideStroke(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
return;
}
if (renderVersion === 8) {
drawHubEllipseGridRotated(ctx, cx, cy, r, rotY, layoutScale);
return;
}
if (renderVersion === 9) {
drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
return;
}
if (renderVersion === 10) {
drawSphereGridScreenStrip(ctx, cx, cy, r, rotY, layoutScale);
return;
}
if (renderVersion === 11) {
drawSphereGridV11(ctx, cx, cy, r, rotY, layoutScale);
return;
}
if (renderVersion >= 2) {
drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
return;
}
drawHubGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false });
}
/** Sunlight overlay — screen highlight + multiply shadow (globe3d=11). */
function drawGlobeSunlight(ctx, cx, cy, r, sunlightStyle) {
const sl = sunlightStyle || resolveSunlightStyle(null);
if (sl.intensity <= 0) {
return;
}
const ends = sunlightGradientEnds(sl, cx, cy, r);
const hi = sl.highlight * sl.intensity;
const sh = sl.shadow * sl.intensity;
const rect = { x: cx - r, y: cy - r, w: r * 2, h: r * 2 };
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
const gLight = ctx.createLinearGradient(ends.x1, ends.y1, ends.x2, ends.y2);
gLight.addColorStop(0, 'rgba(196,220,255,' + Math.min(1, hi * 0.95).toFixed(3) + ')');
gLight.addColorStop(0.38, 'rgba(140,170,210,' + Math.min(1, hi * 0.28).toFixed(3) + ')');
gLight.addColorStop(0.58, 'rgba(255,255,255,0)');
gLight.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gLight;
ctx.globalCompositeOperation = 'screen';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
const gDark = ctx.createLinearGradient(ends.x1, ends.y1, ends.x2, ends.y2);
gDark.addColorStop(0, 'rgba(255,255,255,0)');
gDark.addColorStop(0.48, 'rgba(255,255,255,0)');
gDark.addColorStop(0.78, 'rgba(18,28,44,' + Math.min(1, sh * 0.45).toFixed(3) + ')');
gDark.addColorStop(1, 'rgba(0,0,0,' + Math.min(1, sh).toFixed(3) + ')');
ctx.fillStyle = gDark;
ctx.globalCompositeOperation = 'multiply';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
}
/** Atmospheric halo outside the disk (globe3d=11). Canvas must include fogPadCss margin. */
function drawGlobeFog(ctx, cx, cy, r, fogStyle) {
const fog = fogStyle || resolveFogStyle(null);
if (fog.intensity <= 0) {
return;
}
const outerR = r * (1 + fog.outer);
const innerR = r * fog.inner;
const peakA = (0.32 * fog.intensity).toFixed(3);
const midA = (0.2 * fog.intensity).toFixed(3);
ctx.save();
ctx.globalCompositeOperation = 'screen';
const g = ctx.createRadialGradient(cx, cy, innerR, cx, cy, outerR);
g.addColorStop(0, 'rgba(88,128,168,0)');
g.addColorStop(0.45, 'rgba(64,98,138,' + midA + ')');
g.addColorStop(0.78, 'rgba(48,78,112,' + peakA + ')');
g.addColorStop(1, 'rgba(18,30,48,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, outerR, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
}
/** Limb ring at disk radius r — earth/fog boundary (?cba / ?cbr… + grid halo for contrast). */
function drawGlobeBodyEdge(ctx, cx, cy, r, continentStyle, layoutScale) {
const cs = continentStyle || resolveContinentStyle(null);
const edge = continentBorderRgba(cs, CONTINENT_FILL_RGB);
const grid = gridLineStrokeStyle(true, layoutScale, 1);
const lineW = Math.max(2.5, layoutScale * 0.008);
const core = 'rgba(' + edge.r + ',' + edge.g + ',' + edge.b + ',' + edge.a.toFixed(3) + ')';
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(' + grid.rgb + ',0.38)';
ctx.lineWidth = lineW * 3.2;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(' + edge.r + ',' + edge.g + ',' + edge.b + ',' + Math.min(1, edge.a * 0.55).toFixed(3) + ')';
ctx.lineWidth = lineW * 1.65;
ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = core;
ctx.lineWidth = lineW;
ctx.stroke();
ctx.restore();
}
function drawOceanRim(ctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion) {
if (renderVersion === 11) {
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 * 0.96, cx, cy, rim);
g.addColorStop(0, 'rgba(36,58,88,0.55)');
g.addColorStop(0.55, 'rgba(24,42,66,0.88)');
g.addColorStop(1, 'rgba(12,22,36,0.96)');
ctx.fillStyle = g;
ctx.fill();
ctx.restore();
return;
}
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 wrapLon(lon) {
let l = lon;
while (l > Math.PI) {
l -= Math.PI * 2;
}
while (l < -Math.PI) {
l += Math.PI * 2;
}
return l;
}
/** Inverse Mercator row — matches extract_red_polygons.py mercator_y_to_lat. */
function latToMercatorRow(lat, h) {
const clamped = Math.max(-1.52, Math.min(1.52, lat));
const t = 0.5 + Math.asinh(Math.tan(clamped)) / (2 * Math.PI);
return Math.max(0, Math.min(h - 1, (1 - t) * (h - 1)));
}
function sampleMercFlat(mercFlat, lon, lat) {
const u = ((wrapLon(lon) + Math.PI) / (2 * Math.PI)) * (mercFlat.w - 1);
const v = latToMercatorRow(lat, mercFlat.h);
return sampleImageDataBilinearWrapU(mercFlat.data, mercFlat.w, mercFlat.h, u, v);
}
/** Inverse hub-disk → flat plate (same affine as globe SVG), X wraps 360°. */
function sampleFlatPlateFromHub(mercFlat, hpX, hpY) {
const fx = (hpX - CONTINENT_INNER_TX) / CONTINENT_INNER_SX;
const fy = (hpY - CONTINENT_INNER_TY) / CONTINENT_INNER_SY;
if (fy < 0 || fy > CONTINENT_FLAT_H - 1) {
return null;
}
const sx = (fx / CONTINENT_FLAT_W) * mercFlat.w;
const sy = (fy / CONTINENT_FLAT_H) * mercFlat.h;
return sampleImageDataBilinearWrapU(mercFlat.data, mercFlat.w, mercFlat.h, sx, sy);
}
/** Sphere tex → mercator-flat plate (lon center + inverse Mercator Y). */
function sampleMercFlatFromTex(mercFlat, tex, landZoom) {
const ll = texLonLat(tex);
const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex);
return sampleMercFlat(mercFlat, mapped.lon, mapped.lat);
}
function isLandPixel(r, g, b, a) {
return a > 10 && r > 100 && g < 140 && b < 140;
}
/** hub-logo-continents-mercator-{globe,flat}.svg grey-blue fills (not red JPG extract). */
function isHubContinentPixel(r, g, b, a) {
if (a < 8) {
return false;
}
if (r > 168 && g > 198 && b > 218) {
return false;
}
if (r < 48 && g < 58 && b < 82) {
return false;
}
const lum = r * 0.299 + g * 0.587 + b * 0.114;
if (lum < 92 || lum > 238) {
return false;
}
return b >= 100 && g >= 95 && r >= 68;
}
function hubContinentRgba(sample) {
return [sample[0], sample[1], sample[2], 255];
}
/** Fill vs stroke from hub-logo-continents-mercator-flat.svg raster. */
function classifyHubContinentPixel(r, g, b, a) {
if (a < 8) {
return null;
}
if (r > 168 && g > 198 && b > 218) {
return null;
}
const lum = r * 0.299 + g * 0.587 + b * 0.114;
if (lum < 92 || lum > 238) {
return null;
}
if (!(b >= 100 && g >= 95 && r >= 68)) {
return null;
}
if (lum >= 186 || (g > 198 && b > 212)) {
return 'border';
}
return 'fill';
}
/** Solid fill from rasterized globe SVG — skip gradient fringe / drop-shadow. */
function isSolidContinentPixel(r, g, b, a) {
if (a < 72) {
return false;
}
if (r > 168 && g > 198 && b > 218) {
return false;
}
if (r < 58 && g < 68 && b < 95) {
return false;
}
const lum = r * 0.299 + g * 0.587 + b * 0.114;
if (lum < 102 || lum > 218) {
return false;
}
return b >= 105 && g >= 100 && r >= 72;
}
/** 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;
}
/** globe3d=11 — geographic mercator wrap; semi-transparent fill + contrast borders. */
/** Precomputed mercator-flat land mask (0=ocean, 1=fill, 2=border, 3=coast). */
function buildMercLandCache(mercFlat) {
const w = mercFlat.w;
const h = mercFlat.h;
const n = w * h;
const kind = new Uint8Array(n);
const rgb = new Uint8Array(n * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const o = i * 4;
const r = mercFlat.data[o];
const g = mercFlat.data[o + 1];
const b = mercFlat.data[o + 2];
const a = mercFlat.data[o + 3];
const cls = classifyHubContinentPixel(r, g, b, a);
if (!cls) {
continue;
}
kind[i] = cls === 'border' ? 2 : 1;
rgb[i * 3] = r;
rgb[i * 3 + 1] = g;
rgb[i * 3 + 2] = b;
}
}
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
if (kind[i] !== 1) {
continue;
}
if (
(x > 0 && kind[i - 1] === 0) ||
(x < w - 1 && kind[i + 1] === 0) ||
(y > 0 && kind[i - w] === 0) ||
(y < h - 1 && kind[i + w] === 0)
) {
kind[i] = 3;
}
}
}
return { w: w, h: h, kind: kind, rgb: rgb };
}
function sampleLandFromCache(cache, tex, landZoom, continentStyle, viewZ) {
const cs = continentStyle || resolveContinentStyle(null);
if (viewZ <= LIMB_Z) {
return null;
}
const ll = texLonLat(tex);
const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex);
const u = ((wrapLon(mapped.lon) + Math.PI) / (2 * Math.PI)) * (cache.w - 1);
const v = latToMercatorRow(mapped.lat, cache.h);
const x = Math.max(0, Math.min(cache.w - 1.001, u));
const y = Math.max(0, Math.min(cache.h - 1.001, v));
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = Math.min(x0 + 1, cache.w - 1);
const y1 = Math.min(y0 + 1, cache.h - 1);
const fx = x - x0;
const fy = y - y0;
const i00 = y0 * cache.w + x0;
const i10 = y0 * cache.w + x1;
const i01 = y1 * cache.w + x0;
const i11 = y1 * cache.w + x1;
const k = Math.max(cache.kind[i00], cache.kind[i10], cache.kind[i01], cache.kind[i11]);
if (k === 0) {
return null;
}
const w00 = (1 - fx) * (1 - fy);
const w10 = fx * (1 - fy);
const w01 = (1 - fx) * fy;
const w11 = fx * fy;
const r = Math.round(
w00 * cache.rgb[i00 * 3] +
w10 * cache.rgb[i10 * 3] +
w01 * cache.rgb[i01 * 3] +
w11 * cache.rgb[i11 * 3]
);
const g = Math.round(
w00 * cache.rgb[i00 * 3 + 1] +
w10 * cache.rgb[i10 * 3 + 1] +
w01 * cache.rgb[i01 * 3 + 1] +
w11 * cache.rgb[i11 * 3 + 1]
);
const b = Math.round(
w00 * cache.rgb[i00 * 3 + 2] +
w10 * cache.rgb[i10 * 3 + 2] +
w01 * cache.rgb[i01 * 3 + 2] +
w11 * cache.rgb[i11 * 3 + 2]
);
if (k === 2 || k === 3) {
return [
Math.min(255, r + cs.borderBoostR),
Math.min(255, g + cs.borderBoostG),
Math.min(255, b + cs.borderBoostB),
cs.borderAlpha,
];
}
return [r, g, b, cs.fillAlpha];
}
/** Hot path — land disk without per-pixel fn dispatch (globe3d=11). */
function fillLandDiskFromCache(ctx, cx, cy, r, rotY, cache, landZoom, continentStyle) {
const cs = continentStyle || resolveContinentStyle(null);
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;
const cw = cache.w;
const ch = cache.h;
const cwm1 = cw - 1.001;
const chm1 = ch - 1.001;
let p = 0;
for (let dy = y0; dy <= y1; dy++) {
const oy = dy - cy;
const oyR = oy / r;
const oyR2 = oyR * oyR;
for (let dx = x0; dx <= x1; dx++) {
const ox = dx - cx;
if (ox * ox + oy * oy > r2) {
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
continue;
}
const nx = ox / r;
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - oyR2));
if (nz <= LIMB_Z) {
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
continue;
}
const tex = bodyToTex(inverseRotateY(nx, oyR, nz, rotY));
const ll = texLonLat(tex);
const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex);
const u = ((wrapLon(mapped.lon) + Math.PI) / (2 * Math.PI)) * (cw - 1);
const v = latToMercatorRow(mapped.lat, ch);
const xf = Math.max(0, Math.min(cwm1, u));
const yf = Math.max(0, Math.min(chm1, v));
const x0i = xf | 0;
const y0i = yf | 0;
const x1i = x0i + 1 < cw ? x0i + 1 : x0i;
const y1i = y0i + 1 < ch ? y0i + 1 : y0i;
const fx = xf - x0i;
const fy = yf - y0i;
const i00 = y0i * cw + x0i;
const i10 = y0i * cw + x1i;
const i01 = y1i * cw + x0i;
const i11 = y1i * cw + x1i;
const k = Math.max(cache.kind[i00], cache.kind[i10], cache.kind[i01], cache.kind[i11]);
if (k === 0) {
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
continue;
}
const w00 = (1 - fx) * (1 - fy);
const w10 = fx * (1 - fy);
const w01 = (1 - fx) * fy;
const w11 = fx * fy;
const rgb = cache.rgb;
let rv = Math.round(
w00 * rgb[i00 * 3] +
w10 * rgb[i10 * 3] +
w01 * rgb[i01 * 3] +
w11 * rgb[i11 * 3]
);
let gv = Math.round(
w00 * rgb[i00 * 3 + 1] +
w10 * rgb[i10 * 3 + 1] +
w01 * rgb[i01 * 3 + 1] +
w11 * rgb[i11 * 3 + 1]
);
let bv = Math.round(
w00 * rgb[i00 * 3 + 2] +
w10 * rgb[i10 * 3 + 2] +
w01 * rgb[i01 * 3 + 2] +
w11 * rgb[i11 * 3 + 2]
);
if (k === 2 || k === 3) {
rv = Math.min(255, rv + cs.borderBoostR);
gv = Math.min(255, gv + cs.borderBoostG);
bv = Math.min(255, bv + cs.borderBoostB);
px[p++] = rv;
px[p++] = gv;
px[p++] = bv;
px[p++] = cs.borderAlpha;
} else {
px[p++] = rv;
px[p++] = gv;
px[p++] = bv;
px[p++] = cs.fillAlpha;
}
}
}
return { out: out, x0: x0, y0: y0, stride: 1 };
}
function fillDiskRegion(ctx, cx, cy, r, rotY, fn, step) {
const stride = step && step > 1 ? step : 1;
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 = Math.ceil((x1 - x0 + 1) / stride);
const h = Math.ceil((y1 - y0 + 1) / stride);
if (w <= 0 || h <= 0) {
return null;
}
const out = ctx.createImageData(w, h);
const px = out.data;
let p = 0;
for (let j = 0; j < h; j++) {
const dy = y0 + j * stride;
for (let i = 0; i < w; i++) {
const dx = x0 + i * stride;
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, nz);
px[p++] = rgba[0];
px[p++] = rgba[1];
px[p++] = rgba[2];
px[p++] = rgba[3] !== undefined ? rgba[3] : 255;
}
}
return { out: out, x0: x0, y0: y0, stride: stride, w: w, h: h };
}
function drawSolidOceanDisk(ctx, cx, cy, r) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle =
'rgb(' + OCEAN_FALLBACK.r + ',' + OCEAN_FALLBACK.g + ',' + OCEAN_FALLBACK.b + ')';
ctx.fill();
ctx.restore();
}
function blitLayer(ctx, cx, cy, r, layer, scratch) {
if (!layer) {
return scratch;
}
const { out, x0, y0, stride } = layer;
const sw = out.width;
const sh = out.height;
const dw = sw * stride;
const dh = sh * stride;
if (!scratch || scratch.width < dw || scratch.height < dh) {
scratch = document.createElement('canvas');
scratch.width = dw;
scratch.height = dh;
}
const sctx = scratch.getContext('2d');
if (stride === 1) {
sctx.putImageData(out, 0, 0);
} else {
const small = scratch._smallCanvas || document.createElement('canvas');
if (small.width !== sw || small.height !== sh) {
small.width = sw;
small.height = sh;
}
scratch._smallCanvas = small;
small.getContext('2d').putImageData(out, 0, 0);
sctx.clearRect(0, 0, dw, dh);
sctx.imageSmoothingEnabled = true;
sctx.imageSmoothingQuality = 'high';
sctx.drawImage(small, 0, 0, sw, sh, 0, 0, dw, dh);
}
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(scratch, x0, y0, dw, dh);
ctx.restore();
return scratch;
}
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 landZoom = resolveLandZoom(opts.landZoom);
const continentStyle = resolveContinentStyle(opts.continentStyle);
const sunlightStyle = resolveSunlightStyle(opts.sunlightStyle);
const fogStyle = resolveFogStyle(opts.fogStyle);
const spinFactor = resolveSpinFactor(opts.spinFactor);
const spinTarget = AUTO_SPIN_RAD_S * spinFactor;
const globePerf = resolveGlobePerf(opts.globePerf);
const wantGpuLand = renderVersion >= 11 && globePerf.useGpu;
const canvasList = [];
let bodyCanvas = null;
let landCanvas = null;
let overlayCanvas = null;
let canvas = null;
function makeCanvas(className) {
const c = document.createElement('canvas');
c.className = className;
if (renderVersion >= 11 && fogStyle.intensity > 0 && fogStyle.outer > 0) {
c.classList.add('hub-globe-canvas--halo');
}
c.setAttribute('aria-hidden', 'true');
stage.appendChild(c);
canvasList.push(c);
return c;
}
if (wantGpuLand) {
bodyCanvas = makeCanvas('hub-globe-canvas hub-globe-canvas--body');
landCanvas = makeCanvas('hub-globe-canvas hub-globe-canvas--land');
overlayCanvas = makeCanvas('hub-globe-canvas hub-globe-canvas--overlay');
canvas = overlayCanvas;
} else {
canvas = makeCanvas('hub-globe-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);
canvasList.forEach(function (c) {
c.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);
const mercLandCache = renderVersion >= 11 ? buildMercLandCache(mercFlat) : null;
let gpuRenderer = null;
let useGpuLand = false;
if (wantGpuLand && landCanvas && mercLandCache) {
try {
const gpuMod = await import(hubAsset('assets/hub-globe-gpu.js', build));
gpuRenderer = gpuMod.createGpuGlobeRenderer(landCanvas, {
cache: mercLandCache,
continentStyle: continentStyle,
landZoom: landZoom,
lonCenter: MERCATOR_LON_CENTER,
});
if (gpuRenderer) {
useGpuLand = true;
}
} catch (gpuErr) {
console.warn('HubGlobe: GPU land unavailable', gpuErr);
}
}
if (wantGpuLand && !useGpuLand) {
if (bodyCanvas) {
bodyCanvas.remove();
canvasList.splice(canvasList.indexOf(bodyCanvas), 1);
bodyCanvas = null;
}
if (landCanvas) {
landCanvas.remove();
canvasList.splice(canvasList.indexOf(landCanvas), 1);
landCanvas = null;
}
if (overlayCanvas) {
overlayCanvas.className = 'hub-globe-canvas';
canvas = overlayCanvas;
}
}
let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0;
let angularVelocity = reducedMotion ? 0 : spinTarget;
let dragging = false;
let lastPointerX = 0;
let lastPointerTime = 0;
let raf = 0;
let lastFrame = performance.now();
let lastRenderAt = 0;
let landScratch = null;
let baseDiskCache = null;
let baseDiskCacheR = 0;
let decorCache = null;
let decorCacheKey = '';
let heroVisible = true;
let heroIo = null;
let tickActive = false;
let tickTimer = 0;
const ctx = canvas.getContext('2d', { willReadFrequently: renderVersion < 11 && !useGpuLand });
const bodyCtx = bodyCanvas ? bodyCanvas.getContext('2d') : null;
const invalidateLayerCaches = () => {
baseDiskCache = null;
baseDiskCacheR = 0;
decorCache = null;
decorCacheKey = '';
};
const rebuildBaseDiskCache = (r) => {
const size = Math.ceil(r * 2);
const c = document.createElement('canvas');
c.width = size;
c.height = size;
const bctx = c.getContext('2d');
drawSolidOceanDisk(bctx, r, r, r);
drawGlobeSunlight(bctx, r, r, r, sunlightStyle);
baseDiskCache = c;
baseDiskCacheR = r;
};
const rebuildDecorCache = (width, height, cx, cy, r, rim, layoutScale) => {
const c = document.createElement('canvas');
c.width = width;
c.height = height;
const dctx = c.getContext('2d');
drawOceanRim(dctx, globeImg, cx, cy, r, rim, layoutScale, renderVersion);
drawGlobeFog(dctx, cx, cy, r, fogStyle);
drawGlobeBodyEdge(dctx, cx, cy, r, continentStyle, layoutScale);
decorCache = c;
decorCacheKey =
width + 'x' + height + '@' + Math.round(cx) + ',' + Math.round(cy) + ',' + Math.round(r);
};
const applyCanvasGeometry = (c) => {
const rect = globeCoverRect();
const dpr = Math.min(
window.devicePixelRatio || 1,
renderVersion >= 11 ? globePerf.dprMax : 2
);
const padCss = renderVersion >= 11 ? fogPadCss(rect, fogStyle) : 0;
const cssW = rect.width + padCss * 2;
const cssH = rect.height + padCss * 2;
c.style.left = rect.left - padCss + 'px';
c.style.top = rect.top - padCss + 'px';
c.style.width = cssW + 'px';
c.style.height = cssH + 'px';
c.width = Math.max(64, Math.floor(cssW * dpr));
c.height = Math.max(64, Math.floor(cssH * dpr));
return { rect, dpr, padCss };
};
const resize = () => {
const layout = applyCanvasGeometry(canvas);
if (bodyCanvas) {
applyCanvasGeometry(bodyCanvas);
}
if (landCanvas) {
applyCanvasGeometry(landCanvas);
}
invalidateLayerCaches();
return layout;
};
let layout = resize();
const onWindowResize = () => {
layout = resize();
};
window.addEventListener('resize', onWindowResize);
const heroPage = document.getElementById('landing-page-1');
const scrollRoot = document.getElementById('landing-scroll');
if (heroPage && scrollRoot && typeof IntersectionObserver !== 'undefined') {
heroIo = new IntersectionObserver(
function (entries) {
heroVisible = !!(entries[0] && entries[0].isIntersecting);
if (heroVisible && !document.hidden) {
startTick();
} else {
stopTick();
}
},
{ root: scrollRoot, threshold: 0.08 }
);
heroIo.observe(heroPage);
}
const onVisibility = () => {
if (document.hidden || !heroVisible) {
stopTick();
} else {
startTick();
}
};
document.addEventListener('visibilitychange', onVisibility);
const spinFrameMs = 1000 / globePerf.spinFps;
const render = () => {
const t0 = globePerf.profile ? performance.now() : 0;
const { rect, dpr, padCss } = layout;
const padPx = padCss * dpr;
const cx = (rect.cx - rect.left) * dpr + padPx;
const cy = (rect.cy - rect.top) * dpr + padPx;
const r = rect.r * dpr;
const rim = rect.rim * dpr;
const layoutScale = rect.scale * dpr;
if (useGpuLand && bodyCtx && gpuRenderer && bodyCanvas && landCanvas) {
if (!baseDiskCache || baseDiskCacheR !== r) {
rebuildBaseDiskCache(r);
}
bodyCtx.clearRect(0, 0, bodyCanvas.width, bodyCanvas.height);
bodyCtx.drawImage(baseDiskCache, cx - r, cy - r);
gpuRenderer.draw(cx, cy, r, rotation, landCanvas.width, landCanvas.height);
ctx.clearRect(0, 0, canvas.width, canvas.height);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (renderVersion >= 11) {
if (!baseDiskCache || baseDiskCacheR !== r) {
rebuildBaseDiskCache(r);
}
ctx.drawImage(baseDiskCache, cx - r, cy - r);
landScratch = blitLayer(
ctx,
cx,
cy,
r,
fillLandDiskFromCache(ctx, cx, cy, r, rotation, mercLandCache, landZoom, continentStyle),
landScratch
);
} else {
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);
if (renderVersion >= 11) {
const dKey =
canvas.width + 'x' + canvas.height + '@' + Math.round(cx) + ',' + Math.round(cy) + ',' + Math.round(r);
if (!decorCache || decorCacheKey !== dKey) {
rebuildDecorCache(canvas.width, canvas.height, cx, cy, r, rim, layoutScale);
}
ctx.drawImage(decorCache, 0, 0);
} else if (!useGpuLand) {
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();
}
if (globePerf.profile) {
console.log(
'[globe] render',
(performance.now() - t0).toFixed(2) + 'ms',
useGpuLand ? 'gpu-land' : 'cpu',
'r=' + Math.round(r)
);
}
};
const scheduleRender = (now) => {
lastRenderAt = now || performance.now();
render();
};
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;
scheduleRender(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) => {
tickActive = false;
const dt = Math.min(0.05, (now - lastFrame) / 1000);
lastFrame = now;
if (!dragging) {
if (!reducedMotion) {
const target = spinTarget;
angularVelocity += (target - angularVelocity) * 1.2 * dt;
if (Math.abs(angularVelocity - target) < 0.004) angularVelocity = target;
} else {
angularVelocity *= Math.exp(-4 * dt);
}
rotation += angularVelocity * dt;
}
if (document.hidden || !heroVisible) {
return;
}
const minInterval = dragging ? 0 : spinFrameMs;
if (now - lastRenderAt < minInterval) {
startTick();
return;
}
scheduleRender(now);
startTick();
};
function startTick() {
if (tickActive) {
return;
}
tickActive = true;
if (!dragging && spinFrameMs >= 34) {
tickTimer = window.setTimeout(function () {
tickTimer = 0;
tick(performance.now());
}, spinFrameMs);
} else {
raf = requestAnimationFrame(tick);
}
}
function stopTick() {
if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
if (tickTimer) {
clearTimeout(tickTimer);
tickTimer = 0;
}
tickActive = false;
}
scheduleRender(performance.now());
startTick();
stage.dataset.globe3d = String(renderVersion);
if (spinFactor !== GLOBE_SPIN_FACTOR_DEFAULT) {
stage.dataset.globeAx = String(spinFactor);
}
if (renderVersion >= 11) {
stage.dataset.globeLatf = String(landZoom.latf);
if (useGpuLand) {
stage.dataset.globeGpu = '1';
}
stage.dataset.globeLngf = String(landZoom.lngf);
stage.dataset.globeZy = String(landZoom.zy);
stage.dataset.globeZx = String(landZoom.zx);
stage.dataset.globePp = String(landZoom.pp);
stage.dataset.globePe = String(landZoom.pe);
stage.dataset.globeCfa = String(continentStyle.fillAlpha);
stage.dataset.globeCba = String(continentStyle.borderAlpha);
stage.dataset.globeCbr = String(continentStyle.borderBoostR);
stage.dataset.globeCbg = String(continentStyle.borderBoostG);
stage.dataset.globeCbb = String(continentStyle.borderBoostB);
stage.dataset.globeCeps = String(continentStyle.coastEps);
stage.dataset.globeSlx = String(sunlightStyle.lx);
stage.dataset.globeSly = String(sunlightStyle.ly);
if (sunlightStyle.dir != null) {
stage.dataset.globeSld = String(sunlightStyle.dir);
stage.dataset.globeSll = String(sunlightStyle.span);
} else {
stage.dataset.globeSlx2 = String(sunlightStyle.lx2);
stage.dataset.globeSly2 = String(sunlightStyle.ly2);
}
stage.dataset.globeSli = String(sunlightStyle.intensity);
stage.dataset.globeSlhi = String(sunlightStyle.highlight);
stage.dataset.globeSlsh = String(sunlightStyle.shadow);
stage.dataset.globeFogi = String(fogStyle.intensity);
stage.dataset.globeFogr = String(fogStyle.outer);
stage.dataset.globeFogin = String(fogStyle.inner);
if (fogStyle.intensity > 0 && fogStyle.outer > 0) {
stage.dataset.globeFogPad = String(fogPadCss(globeCoverRect(), fogStyle));
}
}
stage.dataset.globeAxis =
renderVersion === 11
? 'ew-sphere-ellipse-land-lit-world-mer'
: renderVersion === 10
? 'ew-sphere-ellipse'
: renderVersion === 9
? 'ew-ns-sphere-v4'
: renderVersion >= 8
? 'ew-hub-ellipse-rot'
: renderVersion >= 7
? 'ew-sphere-stroke'
: renderVersion >= 6
? 'ew-sphere-band'
: renderVersion >= 5
? 'ew-hub-ellipse'
: renderVersion >= 3
? 'ew-sphere'
: renderVersion >= 2
? 'ew-ns-sphere'
: 'ew-ns-grid';
return {
destroy() {
stopTick();
window.removeEventListener('resize', onWindowResize);
document.removeEventListener('visibilitychange', onVisibility);
if (heroIo) {
heroIo.disconnect();
heroIo = null;
}
if (gpuRenderer) {
gpuRenderer.destroy();
gpuRenderer = null;
}
canvasList.forEach(function (c) {
c.remove();
});
canvasList.length = 0;
delete stage.dataset.globe3d;
delete stage.dataset.globeAxis;
delete stage.dataset.globeAx;
delete stage.dataset.globeLatf;
delete stage.dataset.globeLngf;
delete stage.dataset.globeZy;
delete stage.dataset.globeZx;
delete stage.dataset.globePp;
delete stage.dataset.globePe;
delete stage.dataset.globeCfa;
delete stage.dataset.globeCba;
delete stage.dataset.globeCbr;
delete stage.dataset.globeCbg;
delete stage.dataset.globeCbb;
delete stage.dataset.globeCeps;
delete stage.dataset.globeSlx;
delete stage.dataset.globeSly;
delete stage.dataset.globeSlx2;
delete stage.dataset.globeSly2;
delete stage.dataset.globeSld;
delete stage.dataset.globeSll;
delete stage.dataset.globeSli;
delete stage.dataset.globeSlhi;
delete stage.dataset.globeSlsh;
delete stage.dataset.globeFogi;
delete stage.dataset.globeFogr;
delete stage.dataset.globeFogin;
delete stage.dataset.globeGpu;
},
};
}