1
0
mirror of git://f0xx.org/ac/ac-be-hub synced 2026-07-29 08:39:42 +03:00
Files
ac-be-hub/assets/hub-globe.js
Anton Afanasyeu 6013b848c5 initial
2026-06-23 12:29:37 +02:00

628 lines
17 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=1).
* Grid: hub SVG ellipses (EW) + meridian paths (NS) traced on sphere, then Y-rotated.
* Land: ortho hub disk on front face; mercator-flat only on back wrap (avoids front squeeze).
*/
export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 };
const AUTO_SPIN_RAD_S = 0.035;
const DRAG_ROTATION_PER_PX = 0.004;
const OCEAN_FALLBACK = { r: 22, g: 38, b: 60 };
const FRONT_Z = 0.04;
const RASTER_SCALE = 2;
const AXIS_STEPS = 160;
/** hub-logo-fragment-globe-grid-overlay.svg — EW parallels. */
const HUB_EW_PARALLELS = [
{ cx: 720, cy: 560, rx: 318, ry: 11, bright: true },
{ cx: 720, cy: 392, rx: 260, ry: 39, bright: false },
{ cx: 720, cy: 728, rx: 260, ry: 39, bright: false },
];
/** Same SVG — NS meridians (line + quadratic beziers). */
const HUB_NS_MERIDIANS = [
{ prime: true, points: sampleLinePoints(720, 248, 720, 872, AXIS_STEPS) },
{ prime: false, points: sampleQuadPoints(620, 248, 358, 560, 620, 872, AXIS_STEPS) },
{ prime: false, points: sampleQuadPoints(820, 248, 1082, 560, 820, 872, AXIS_STEPS) },
];
function sampleLinePoints(x0, y0, x1, y1, steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
pts.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t });
}
return pts;
}
function sampleQuadPoints(x0, y0, cx, cy, x1, y1, steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const u = 1 - t;
pts.push({
x: u * u * x0 + 2 * u * t * cx + t * t * x1,
y: u * u * y0 + 2 * u * t * cy + t * t * y1,
});
}
return pts;
}
function hubAsset(rel, build) {
const link = document.querySelector('link[href*="hub.css"]');
const base = link ? (link.getAttribute('href') || '').replace(/[^/]*$/, '') : '';
const sep = rel.indexOf('?') >= 0 ? '&' : '?';
return base + rel + sep + 'v=' + build;
}
export function globeCoverRect() {
const vw = window.innerWidth;
const vh = window.innerHeight;
const scale = Math.max(vw / GLOBE_VIEW.imgW, vh / GLOBE_VIEW.imgH);
const drawW = GLOBE_VIEW.imgW * scale;
const drawH = GLOBE_VIEW.imgH * scale;
const offX = (vw - drawW) / 2;
const offY = (vh - drawH) / 2;
const left = offX + (GLOBE_VIEW.cx - GLOBE_VIEW.r) * scale;
const top = offY + (GLOBE_VIEW.cy - GLOBE_VIEW.r) * scale;
const size = GLOBE_VIEW.r * 2 * scale;
return {
left,
top,
width: size,
height: size,
scale,
cx: GLOBE_VIEW.cx * scale + offX,
cy: GLOBE_VIEW.cy * scale + offY,
r: GLOBE_VIEW.r * scale,
rim: GLOBE_VIEW.rim * scale,
};
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image load failed: ' + url));
img.src = url;
});
}
function rasterizeImage(img, w, h, scale) {
const s = scale || 1;
const rw = Math.max(1, Math.round(w * s));
const rh = Math.max(1, Math.round(h * s));
const c = document.createElement('canvas');
c.width = rw;
c.height = rh;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, rw, rh);
return { data: ctx.getImageData(0, 0, rw, rh).data, w: rw, h: rh };
}
function sampleImageDataBilinear(imgData, w, h, fx, fy) {
const x = Math.max(0, Math.min(w - 1.001, fx));
const y = Math.max(0, Math.min(h - 1.001, fy));
const x0 = x | 0;
const y0 = y | 0;
const x1 = Math.min(w - 1, x0 + 1);
const y1 = Math.min(h - 1, y0 + 1);
const tx = x - x0;
const ty = y - y0;
const i00 = (y0 * w + x0) * 4;
const i10 = (y0 * w + x1) * 4;
const i01 = (y1 * w + x0) * 4;
const i11 = (y1 * w + x1) * 4;
const out = [0, 0, 0, 0];
for (let c = 0; c < 4; c++) {
out[c] = Math.round(
imgData[i00 + c] * (1 - tx) * (1 - ty) +
imgData[i10 + c] * tx * (1 - ty) +
imgData[i01 + c] * (1 - tx) * ty +
imgData[i11 + c] * tx * ty
);
}
return out;
}
function inverseRotateY(nx, ny, nz, rotY) {
const cosR = Math.cos(rotY);
const sinR = Math.sin(rotY);
return {
x: nx * cosR - nz * sinR,
y: ny,
z: nx * sinR + nz * cosR,
};
}
function bodyToTex(body) {
return { x: body.x, y: -body.y, z: body.z };
}
function bodyToView(body, rotY) {
const cosR = Math.cos(rotY);
const sinR = Math.sin(rotY);
return {
x: body.x * cosR + body.z * sinR,
y: body.y,
z: -body.x * sinR + body.z * cosR,
};
}
/** Hub disk pixel → unit-sphere body (front hemisphere, matches globe3d=0). */
function hubPixelToBody(hpX, hpY) {
const texX = (hpX - GLOBE_VIEW.cx) / GLOBE_VIEW.r;
const texY = (GLOBE_VIEW.cy - hpY) / GLOBE_VIEW.r;
const rr = texX * texX + texY * texY;
if (rr > 1.001) {
return null;
}
return {
x: texX,
y: -texY,
z: Math.sqrt(Math.max(0, 1 - rr)),
};
}
function hubPointsToBodyRing(points) {
const out = [];
for (let i = 0; i < points.length; i++) {
const body = hubPixelToBody(points[i].x, points[i].y);
if (body) {
out.push(body);
}
}
return out;
}
function ellipseBodyRing(ellipse, steps) {
const out = [];
for (let i = 0; i <= steps; i++) {
const a = (i / steps) * Math.PI * 2;
const body = hubPixelToBody(
ellipse.cx + ellipse.rx * Math.cos(a),
ellipse.cy + ellipse.ry * Math.sin(a)
);
if (body) {
out.push(body);
}
}
return out;
}
function parallelStyle(bright, scale, z) {
const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (0.95 - FRONT_Z)));
if (t <= 0) {
return null;
}
const alpha = bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t;
const rgb = bright ? '219,231,246' : '231,236,243';
return {
color: 'rgba(' + rgb + ',' + alpha.toFixed(3) + ')',
width: Math.max(bright ? 3 : 2, scale * (bright ? 0.009 : 0.006)) * (0.5 + 0.5 * t),
};
}
function meridianStyle(prime, scale, z) {
const t = Math.max(0, Math.min(1, (z - FRONT_Z) / (0.95 - FRONT_Z)));
if (t <= 0) {
return null;
}
const alpha = prime ? 0.5 + 0.48 * t : 0.3 + 0.45 * t;
return {
color: 'rgba(238,245,255,' + alpha.toFixed(3) + ')',
width: Math.max(prime ? 3 : 2.5, scale * (prime ? 0.0095 : 0.008)) * (0.45 + 0.55 * t),
};
}
function strokeBodyRing(ctx, cx, cy, r, rotY, bodies, styleAt) {
let run = [];
function flush() {
if (run.length < 2) {
run = [];
return;
}
let zSum = 0;
for (let i = 0; i < run.length; i++) {
zSum += run[i].z;
}
const style = styleAt(zSum / run.length);
if (!style) {
run = [];
return;
}
ctx.beginPath();
ctx.moveTo(run[0].sx, run[0].sy);
for (let i = 1; i < run.length; i++) {
ctx.lineTo(run[i].sx, run[i].sy);
}
ctx.strokeStyle = style.color;
ctx.lineWidth = style.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
run = [];
}
for (let i = 0; i < bodies.length; i++) {
const view = bodyToView(bodies[i], rotY);
if (view.z <= FRONT_Z) {
flush();
continue;
}
run.push({
sx: cx + view.x * r,
sy: cy - view.y * r,
z: view.z,
});
}
flush();
}
/** Hub SVG grid: EW ellipses + NS meridian paths (matches globe3d=0 artwork geometry). */
function drawHubGrid(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);
});
});
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 sampleHub(layer, tex) {
const hp = {
x: GLOBE_VIEW.cx + tex.x * GLOBE_VIEW.r,
y: GLOBE_VIEW.cy - tex.y * GLOBE_VIEW.r,
};
const sx = (hp.x / GLOBE_VIEW.imgW) * (layer.w - 1);
const sy = (hp.y / GLOBE_VIEW.imgH) * (layer.h - 1);
return sampleImageDataBilinear(layer.data, layer.w, layer.h, sx, sy);
}
function texLonLat(tex) {
return {
lon: Math.atan2(tex.x, tex.z),
lat: Math.asin(Math.max(-1, Math.min(1, tex.y))),
};
}
function sampleMercFlat(mercFlat, lon, lat) {
const u = ((lon + Math.PI) / (2 * Math.PI)) * (mercFlat.w - 1);
const v = (0.5 - lat / Math.PI) * (mercFlat.h - 1);
return sampleImageDataBilinear(mercFlat.data, mercFlat.w, mercFlat.h, u, v);
}
function isLandPixel(r, g, b, a) {
return a > 10 && r > 100 && g < 140 && b < 140;
}
/** Light hub grid strokes baked into hub-logo-fragment-globe.svg (vector grid drawn separately). */
function isGridPixel(r, g, b, a) {
if (a < 12) {
return false;
}
const lum = r * 0.299 + g * 0.587 + b * 0.114;
if (lum < 125) {
return false;
}
if (g < 155 || b < 155) {
return false;
}
return Math.max(r, g, b) - Math.min(r, g, b) < 85;
}
function sampleOceanRgba(globe, tex) {
if (tex.z <= FRONT_Z) {
return [OCEAN_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255];
}
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_FALLBACK.r, OCEAN_FALLBACK.g, OCEAN_FALLBACK.b, 255];
}
function sampleLandRgba(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;
}
function fillDiskRegion(ctx, cx, cy, r, rotY, fn) {
const r2 = r * r;
const x0 = Math.max(0, Math.floor(cx - r));
const y0 = Math.max(0, Math.floor(cy - r));
const x1 = Math.min(ctx.canvas.width - 1, Math.ceil(cx + r));
const y1 = Math.min(ctx.canvas.height - 1, Math.ceil(cy + r));
const w = x1 - x0 + 1;
const h = y1 - y0 + 1;
if (w <= 0 || h <= 0) {
return null;
}
const out = ctx.createImageData(w, h);
const px = out.data;
let p = 0;
for (let dy = y0; dy <= y1; dy++) {
for (let dx = x0; dx <= x1; dx++) {
const ox = dx - cx;
const oy = dy - cy;
if (ox * ox + oy * oy > r2) {
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
px[p++] = 0;
continue;
}
const nx = ox / r;
const ny = oy / r;
const nz = Math.sqrt(Math.max(0, 1 - nx * nx - ny * ny));
const tex = bodyToTex(inverseRotateY(nx, ny, nz, rotY));
const rgba = fn(tex);
px[p++] = rgba[0];
px[p++] = rgba[1];
px[p++] = rgba[2];
px[p++] = rgba[3] !== undefined ? rgba[3] : 255;
}
}
return { out, x0, y0 };
}
function blitLayer(ctx, cx, cy, r, layer) {
if (!layer) {
return;
}
const { out, x0, y0 } = layer;
const w = out.width;
const h = out.height;
const src = out.data;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
const existing = ctx.getImageData(x0, y0, w, h);
const dst = existing.data;
for (let i = 0; i < src.length; i += 4) {
const sa = src[i + 3];
if (sa === 0) {
continue;
}
if (sa === 255) {
dst[i] = src[i];
dst[i + 1] = src[i + 1];
dst[i + 2] = src[i + 2];
dst[i + 3] = 255;
continue;
}
const a = sa / 255;
dst[i] = Math.round(src[i] * a + dst[i] * (1 - a));
dst[i + 1] = Math.round(src[i + 1] * a + dst[i + 1] * (1 - a));
dst[i + 2] = Math.round(src[i + 2] * a + dst[i + 2] * (1 - a));
dst[i + 3] = 255;
}
ctx.putImageData(existing, x0, y0);
ctx.restore();
}
function drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale) {
const drawW = GLOBE_VIEW.imgW * layoutScale;
const drawH = GLOBE_VIEW.imgH * layoutScale;
const dx = cx - GLOBE_VIEW.cx * layoutScale;
const dy = cy - GLOBE_VIEW.cy * layoutScale;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, rim, 0, Math.PI * 2);
ctx.arc(cx, cy, r, 0, Math.PI * 2, true);
ctx.clip();
ctx.drawImage(globeImg, 0, 0, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, dx, dy, drawW, drawH);
ctx.restore();
}
export async function mountHubGlobe(stage, opts) {
const build = opts.build || 'earth3d';
const reducedMotion = !!opts.reducedMotion;
const canvas = document.createElement('canvas');
canvas.className = 'hub-globe-canvas';
canvas.setAttribute('aria-hidden', 'true');
stage.appendChild(canvas);
const urls = {
globe: hubAsset('assets/hub-logo-fragment-globe.svg', build),
continents: hubAsset('assets/hub-logo-continents-mercator-globe.svg', build),
mercatorFlat: hubAsset('assets/hub-logo-continents-mercator-flat.svg', build),
};
let globeImg;
let continentsImg;
let mercatorFlatImg;
try {
[globeImg, continentsImg, mercatorFlatImg] = await Promise.all([
loadImage(urls.globe),
loadImage(urls.continents),
loadImage(urls.mercatorFlat),
]);
} catch (e) {
console.warn('HubGlobe: asset load failed', e);
canvas.remove();
return null;
}
const globe = rasterizeImage(globeImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE);
const continents = rasterizeImage(continentsImg, GLOBE_VIEW.imgW, GLOBE_VIEW.imgH, RASTER_SCALE);
const mercW = (mercatorFlatImg.naturalWidth || 800) * 2;
const mercH = (mercatorFlatImg.naturalHeight || 519) * 2;
const mercFlat = rasterizeImage(mercatorFlatImg, mercW, mercH, 1);
let rotation = typeof opts.initialRotationY === 'number' ? opts.initialRotationY : 0;
let angularVelocity = reducedMotion ? 0 : AUTO_SPIN_RAD_S;
let dragging = false;
let lastPointerX = 0;
let lastPointerTime = 0;
let raf = 0;
let lastFrame = performance.now();
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const resize = () => {
const rect = globeCoverRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const px = Math.max(64, Math.floor(rect.width * dpr));
canvas.style.left = rect.left + 'px';
canvas.style.top = rect.top + 'px';
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
canvas.width = px;
canvas.height = px;
return { rect, dpr, px };
};
let layout = resize();
window.addEventListener('resize', () => {
layout = resize();
});
const render = () => {
const { rect, dpr, px } = layout;
const cx = (rect.cx - rect.left) * dpr;
const cy = (rect.cy - rect.top) * dpr;
const r = rect.r * dpr;
const rim = rect.rim * dpr;
const layoutScale = rect.scale * dpr;
ctx.clearRect(0, 0, px, px);
blitLayer(
ctx,
cx,
cy,
r,
fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) {
return sampleOceanRgba(globe, tex);
})
);
blitLayer(
ctx,
cx,
cy,
r,
fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) {
const l = sampleLandRgba(continents, mercFlat, tex);
return l || [0, 0, 0, 0];
})
);
drawHubGrid(ctx, cx, cy, r, rotation, layoutScale);
drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(10,14,20,0.55)';
ctx.lineWidth = Math.max(2, layoutScale * 0.006);
ctx.stroke();
ctx.restore();
};
const onPointerDown = (ev) => {
if (ev.button !== 0) return;
dragging = true;
lastPointerX = ev.clientX;
lastPointerTime = performance.now();
canvas.setPointerCapture(ev.pointerId);
canvas.classList.add('is-dragging');
ev.preventDefault();
};
const onPointerMove = (ev) => {
if (!dragging) return;
const now = performance.now();
const dx = ev.clientX - lastPointerX;
rotation += dx * DRAG_ROTATION_PER_PX;
if (now > lastPointerTime) {
const inst = (dx * DRAG_ROTATION_PER_PX) / ((now - lastPointerTime) / 1000);
angularVelocity = Math.max(-2.5, Math.min(2.5, inst));
}
lastPointerX = ev.clientX;
lastPointerTime = now;
ev.preventDefault();
};
const endDrag = (ev) => {
if (!dragging) return;
dragging = false;
canvas.classList.remove('is-dragging');
try {
canvas.releasePointerCapture(ev.pointerId);
} catch (ignored) {}
};
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', endDrag);
canvas.addEventListener('pointercancel', endDrag);
const tick = (now) => {
raf = requestAnimationFrame(tick);
const dt = Math.min(0.05, (now - lastFrame) / 1000);
lastFrame = now;
if (!dragging) {
if (!reducedMotion) {
const target = AUTO_SPIN_RAD_S;
angularVelocity += (target - angularVelocity) * 1.2 * dt;
if (Math.abs(angularVelocity - target) < 0.004) angularVelocity = target;
} else {
angularVelocity *= Math.exp(-4 * dt);
}
rotation += angularVelocity * dt;
}
render();
};
raf = requestAnimationFrame(tick);
stage.dataset.globe3d = 'active';
stage.dataset.globeAxis = 'ew-ns-grid';
return {
destroy() {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
canvas.remove();
delete stage.dataset.globe3d;
delete stage.dataset.globeAxis;
},
};
}