diff --git a/assets/hub-globe.js b/assets/hub-globe.js index 8f8ae80..ccc7046 100644 --- a/assets/hub-globe.js +++ b/assets/hub-globe.js @@ -6,7 +6,8 @@ * 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 + hub ry band fill (latitude strips, limb fade). + * globe3d=6: v4 math + latitude band fill (continuous arc paths). + * globe3d=7: v4 math + wide centerline stroke bands (no fill quads). */ export const GLOBE_VIEW = { imgW: 1920, imgH: 1080, cx: 720, cy: 560, r: 322, rim: 330 }; @@ -325,44 +326,81 @@ function parallelBandHalfWidth(par) { return hubRy / GLOBE_VIEW.r; } -/** Filled E–W band between two latitude rings (hub SVG ry → sphere half-width). */ -function fillParallelBand(ctx, cx, cy, r, rotY, par, layoutScale) { +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 inner = parallelBodyRingAtLat(latLo, SPHERE_AXIS_STEPS); const outer = parallelBodyRingAtLat(latHi, SPHERE_AXIS_STEPS); - const n = Math.min(inner.length, outer.length); + 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 = []; - for (let i = 0; i < n - 1; i++) { - const views = [ - bodyToView(inner[i], rotY), - bodyToView(inner[i + 1], rotY), - bodyToView(outer[i + 1], rotY), - bodyToView(outer[i], rotY), - ]; - const zAvg = (views[0].z + views[1].z + views[2].z + views[3].z) / 4; - if (zAvg <= LIMB_Z) { - continue; + function flushFill() { + if (runO.length < 2) { + runO = []; + runI = []; + return; } - const zMin = Math.min(views[0].z, views[1].z, views[2].z, views[3].z); + 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))); - const limbFade = Math.min(1, Math.max(0, (zMin - LIMB_Z) / 0.12)); - const alpha = (par.bright ? 0.2 + 0.55 * t : 0.1 + 0.38 * t) * limbFade; - if (alpha <= 0.008) { + 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; } - ctx.beginPath(); - ctx.moveTo(cx + views[0].x * r, cy - views[0].y * r); - ctx.lineTo(cx + views[1].x * r, cy - views[1].y * r); - ctx.lineTo(cx + views[2].x * r, cy - views[2].y * r); - ctx.lineTo(cx + views[3].x * r, cy - views[3].y * r); - ctx.closePath(); - ctx.fillStyle = 'rgba(' + rgb + ',' + alpha.toFixed(3) + ')'; - ctx.fill(); + 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, @@ -372,19 +410,50 @@ function fillParallelBand(ctx, cx, cy, r, rotY, par, layoutScale) { rotY, parallelBodyRingAtLat(lat, SPHERE_AXIS_STEPS), function (z) { - const style = parallelStyle(par.bright, layoutScale, z, LIMB_Z); + const style = parallelStyle(par.bright, 1, z, LIMB_Z); if (!style) { return null; } return { color: style.color, - width: style.width * (par.bright ? 1.15 : 1.05), + 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); @@ -394,7 +463,39 @@ function drawSphereGridBanded(ctx, cx, cy, r, rotY, layoutScale, opts) { ctx.clip(); SPHERE_PARALLELS.forEach(function (par) { - fillParallelBand(ctx, cx, cy, r, rotY, par, layoutScale); + 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(); +} + +/** 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) { @@ -518,6 +619,10 @@ function drawGlobeGrid(ctx, cx, cy, r, rotY, layoutScale, renderVersion) { 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 >= 2) { drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; @@ -909,15 +1014,17 @@ export async function mountHubGlobe(stage, opts) { raf = requestAnimationFrame(tick); stage.dataset.globe3d = String(renderVersion); stage.dataset.globeAxis = - renderVersion >= 6 - ? 'ew-sphere-band' - : renderVersion >= 5 - ? 'ew-hub-ellipse' - : renderVersion >= 3 - ? 'ew-sphere' - : renderVersion >= 2 - ? 'ew-ns-sphere' - : 'ew-ns-grid'; + 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() { diff --git a/index.php b/index.php index 1bb9cba..e888047 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 = '20260704globe6'; + var logoBuild = '20260704globe7'; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]');