mirror of
git://f0xx.org/ac/ac-be-hub
synced 2026-07-29 04:59:44 +03:00
initial
This commit is contained in:
627
assets/hub-globe.js
Normal file
627
assets/hub-globe.js
Normal file
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* Hub landing globe — orthographic Canvas2D (globe3d=1).
|
||||
* Grid: hub SVG ellipses (E–W) + meridian paths (N–S) 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 — 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 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: E–W ellipses + N–S 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user