mirror of
git://f0xx.org/ac/ac-be-hub
synced 2026-08-09 20:59:23 +03:00
Refine 3D globe rendering, layer CSS, landing page wiring and TOC UX. Co-authored-by: Cursor <cursoragent@cursor.com>
2288 lines
67 KiB
JavaScript
2288 lines
67 KiB
JavaScript
/**
|
||
* 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, E–W 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 E–W + N–S) — 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 N–S 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 — E–W 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 — N–S 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 E–W 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 E–W 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 E–W ellipses on sphere; v4 N–S 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 E–W 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 E–W 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: E–W ellipses + N–S 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;
|
||
},
|
||
};
|
||
}
|