From 9cfe5ff5bd30826a05641ba0e69b4df2fc0a557d Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Wed, 5 Aug 2026 22:37:20 +0200 Subject: [PATCH] feat(globe3d): ?ax spin factor and v11 continent transparency Expose URL ?ax=N as auto-spin speed multiplier for all interactive globe versions. On globe3d=11, render semi-transparent continent fill with brighter coastline/border pixels matching the static SVG art. Co-authored-by: Cursor --- assets/hub-globe.js | 75 +++++++++++++++++++++++++++++++++++++++++---- index.php | 11 ++++++- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/assets/hub-globe.js b/assets/hub-globe.js index ff47f5a..daf077f 100644 --- a/assets/hub-globe.js +++ b/assets/hub-globe.js @@ -13,6 +13,7 @@ * 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=2 — auto-spin speed factor (1 = default, 2 = double, …). */ export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 }; @@ -52,6 +53,14 @@ function resolveLandZoom(raw) { }; } +/** URL ?ax=N — auto-spin speed multiplier (default 1). */ +function resolveSpinFactor(raw) { + if (typeof raw !== 'number' || !isFinite(raw)) { + return 1; + } + return Math.max(0.05, Math.min(8, raw)); +} + /** * Land plate sample remap (globe3d=11 URL tunables): * latf/lngf — linear zoom; zy/zx — equator/prime-meridian mag (1 = uniform). @@ -1025,6 +1034,27 @@ 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) { @@ -1107,16 +1137,43 @@ function sampleLandRgbaMercator(mercFlat, tex) { return null; } -/** globe3d=11 — geographic mercator wrap (360°); z > LIMB_Z only. */ +/** globe3d=11 — geographic mercator wrap; semi-transparent fill + contrast borders. */ function sampleLandVisibleHemisphere(mercFlat, tex, landZoom) { if (tex.z <= LIMB_Z) { return null; } const flat = sampleMercFlatFromTex(mercFlat, tex, landZoom); - if (isHubContinentPixel(flat[0], flat[1], flat[2], flat[3])) { - return hubContinentRgba(flat); + let kind = classifyHubContinentPixel(flat[0], flat[1], flat[2], flat[3]); + if (!kind) { + return null; } - return null; + if (kind === 'fill') { + const ll = texLonLat(tex); + const mapped = landZoomLonLat(ll.lon, ll.lat, landZoom, tex); + const eps = 0.0035; + const neighbors = [ + sampleMercFlat(mercFlat, mapped.lon + eps, mapped.lat), + sampleMercFlat(mercFlat, mapped.lon - eps, mapped.lat), + sampleMercFlat(mercFlat, mapped.lon, mapped.lat + eps), + sampleMercFlat(mercFlat, mapped.lon, mapped.lat - eps), + ]; + for (let i = 0; i < neighbors.length; i++) { + const n = neighbors[i]; + if (!classifyHubContinentPixel(n[0], n[1], n[2], n[3])) { + kind = 'border'; + break; + } + } + } + if (kind === 'border') { + return [ + Math.min(255, flat[0] + 18), + Math.min(255, flat[1] + 14), + Math.min(255, flat[2] + 8), + 232, + ]; + } + return [flat[0], flat[1], flat[2], 148]; } function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { @@ -1219,6 +1276,8 @@ export async function mountHubGlobe(stage, opts) { const reducedMotion = !!opts.reducedMotion; const renderVersion = Math.max(1, parseInt(opts.renderVersion, 10) || 1); const landZoom = resolveLandZoom(opts.landZoom); + const spinFactor = resolveSpinFactor(opts.spinFactor); + const spinTarget = AUTO_SPIN_RAD_S * spinFactor; const canvas = document.createElement('canvas'); canvas.className = 'hub-globe-canvas'; @@ -1253,7 +1312,7 @@ export async function mountHubGlobe(stage, opts) { 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 angularVelocity = reducedMotion ? 0 : spinTarget; let dragging = false; let lastPointerX = 0; let lastPointerTime = 0; @@ -1391,7 +1450,7 @@ export async function mountHubGlobe(stage, opts) { if (!dragging) { if (!reducedMotion) { - const target = AUTO_SPIN_RAD_S; + const target = spinTarget; angularVelocity += (target - angularVelocity) * 1.2 * dt; if (Math.abs(angularVelocity - target) < 0.004) angularVelocity = target; } else { @@ -1405,6 +1464,9 @@ export async function mountHubGlobe(stage, opts) { raf = requestAnimationFrame(tick); stage.dataset.globe3d = String(renderVersion); + if (spinFactor !== 1) { + stage.dataset.globeAx = String(spinFactor); + } if (renderVersion >= 11) { stage.dataset.globeLatf = String(landZoom.latf); stage.dataset.globeLngf = String(landZoom.lngf); @@ -1441,6 +1503,7 @@ export async function mountHubGlobe(stage, opts) { canvas.remove(); delete stage.dataset.globe3d; delete stage.dataset.globeAxis; + delete stage.dataset.globeAx; delete stage.dataset.globeLatf; delete stage.dataset.globeLngf; delete stage.dataset.globeZy; diff --git a/index.php b/index.php index e7cf13a..a993710 100644 --- a/index.php +++ b/index.php @@ -187,7 +187,7 @@ function hub_h(string $s): string { var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover')); var logoEnabled = !!(stage && supportsSvg && supportsLayout); - var logoBuild = '20260704globe29'; + var logoBuild = '20260704globe31'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]'); @@ -268,6 +268,12 @@ function hub_h(string $s): string { var fLegacy = parseFloat(globeLatfLegacy); if (isFinite(fLegacy)) globeLandZoom.latf = fLegacy; } + var globeAxRaw = params.get('ax'); + var globeSpinFactor = 1; + if (globeAxRaw !== null && globeAxRaw !== '') { + var axParsed = parseFloat(globeAxRaw); + if (isFinite(axParsed)) globeSpinFactor = axParsed; + } var useGlobe3d = logoEnabled && globe3dVersion !== 0; var globe3dLayers = { globe: true, continents: true, gridOverlay: true }; @@ -315,6 +321,9 @@ function hub_h(string $s): string { if (Object.keys(globeLandZoom).length > 0) { globeOpts.landZoom = globeLandZoom; } + if (globeSpinFactor !== 1) { + globeOpts.spinFactor = globeSpinFactor; + } var globeModuleUrl = new URL('assets/hub-globe.js', window.location.href); globeModuleUrl.searchParams.set('v', logoBuild); globeModuleUrl.searchParams.set('globe3d', String(globe3dVersion));