Cast
Wi‑Fi and USB transport with adaptive bitrate and session metrics.
-diff --git a/assets/hub-globe-gpu.js b/assets/hub-globe-gpu.js new file mode 100644 index 0000000..096cead --- /dev/null +++ b/assets/hub-globe-gpu.js @@ -0,0 +1,225 @@ +/** + * WebGL land disk (globe3d=11) — GPU orthographic mercator sample; ocean/sun stay Canvas2D. + */ + +const VS = ` +attribute vec2 aPos; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); +} +`; + +const FS = ` +precision highp float; +uniform vec3 uDisk; +uniform float uRotY; +uniform sampler2D uMerc; +uniform float uLatf; +uniform float uLngf; +uniform float uZy; +uniform float uZx; +uniform float uPp; +uniform float uPe; +uniform float uLonCenter; +uniform float uLimbZ; + +const float PI = 3.141592653589793; +const float TAU = 6.283185307179586; + +float wrapLon(float lon) { + lon = mod(lon + PI, TAU); + if (lon < 0.0) lon += TAU; + return lon - PI; +} + +float latToMercatorV(float lat) { + float clamped = clamp(lat, -1.52, 1.52); + float t = 0.5 + asinh(tan(clamped)) / TAU; + return clamp(1.0 - t, 0.0, 1.0); +} + +vec2 landZoomLonLat(float lon, float lat, vec3 tex) { + float latT = min(1.0, abs(lat) / (PI * 0.5)); + float lonT = min(1.0, abs(lon) / PI); + float rimT = min(1.0, length(tex.xz)); + float latMul = uLatf * (uZy * (1.0 - latT * latT) + latT * latT); + float lonMul = uLngf * (uZx * (1.0 - lonT * lonT) + lonT * lonT); + float poleLens = 1.0 - uPp * latT * latT; + float edgeLens = 1.0 - uPe * rimT * rimT; + return vec2( + wrapLon(lon * lonMul * edgeLens + uLonCenter), + clamp(lat * latMul * poleLens, -1.52, 1.52) + ); +} + +void main() { + vec2 p = (gl_FragCoord.xy - uDisk.xy) / uDisk.z; + float rr = dot(p, p); + if (rr > 1.0) { + discard; + } + float nz = sqrt(max(0.0, 1.0 - rr)); + if (nz <= uLimbZ) { + discard; + } + + float cosR = cos(uRotY); + float sinR = sin(uRotY); + float bx = p.x * cosR - nz * sinR; + float by = p.y; + float bz = p.x * sinR + nz * cosR; + vec3 tex = vec3(bx, by, bz); + + float lon = atan(bx, bz); + float lat = asin(clamp(by, -1.0, 1.0)); + vec2 mapped = landZoomLonLat(lon, lat, tex); + float u = fract((mapped.x + PI) / TAU); + float v = latToMercatorV(mapped.y); + vec4 merc = texture2D(uMerc, vec2(u, v)); + float alpha = merc.a / 255.0; + if (alpha <= 0.004) { + discard; + } + gl_FragColor = vec4(merc.rgb / 255.0, alpha); +} +`; + +function compileShader(gl, type, src) { + const sh = gl.createShader(type); + gl.shaderSource(sh, src); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + console.warn('Globe GPU shader:', gl.getShaderInfoLog(sh)); + gl.deleteShader(sh); + return null; + } + return sh; +} + +function buildMercTexture(gl, cache, cs) { + const w = cache.w; + const h = cache.h; + const pixels = new Uint8Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + const k = cache.kind[i]; + if (!k) { + continue; + } + let r = cache.rgb[i * 3]; + let g = cache.rgb[i * 3 + 1]; + let b = cache.rgb[i * 3 + 2]; + let a = cs.fillAlpha; + if (k === 2 || k === 3) { + r = Math.min(255, r + cs.borderBoostR); + g = Math.min(255, g + cs.borderBoostG); + b = Math.min(255, b + cs.borderBoostB); + a = cs.borderAlpha; + } + const o = i * 4; + pixels[o] = r; + pixels[o + 1] = g; + pixels[o + 2] = b; + pixels[o + 3] = a; + } + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + gl.bindTexture(gl.TEXTURE_2D, null); + return tex; +} + +/** + * @param {HTMLCanvasElement} canvas + * @param {{ cache: object, continentStyle: object, landZoom: object, lonCenter: number }} opts + */ +export function createGpuGlobeRenderer(canvas, opts) { + const gl = canvas.getContext('webgl', { + alpha: true, + antialias: false, + depth: false, + stencil: false, + preserveDrawingBuffer: false, + powerPreference: 'high-performance', + }); + if (!gl) { + return null; + } + + const vs = compileShader(gl, gl.VERTEX_SHADER, VS); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, FS); + if (!vs || !fs) { + return null; + } + const prog = gl.createProgram(); + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + console.warn('Globe GPU link:', gl.getProgramInfoLog(prog)); + return null; + } + gl.deleteShader(vs); + gl.deleteShader(fs); + + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW); + + const cs = opts.continentStyle; + const mercTex = buildMercTexture(gl, opts.cache, cs); + const lz = opts.landZoom; + + const loc = { + aPos: gl.getAttribLocation(prog, 'aPos'), + uDisk: gl.getUniformLocation(prog, 'uDisk'), + uRotY: gl.getUniformLocation(prog, 'uRotY'), + uMerc: gl.getUniformLocation(prog, 'uMerc'), + uLatf: gl.getUniformLocation(prog, 'uLatf'), + uLngf: gl.getUniformLocation(prog, 'uLngf'), + uZy: gl.getUniformLocation(prog, 'uZy'), + uZx: gl.getUniformLocation(prog, 'uZx'), + uPp: gl.getUniformLocation(prog, 'uPp'), + uPe: gl.getUniformLocation(prog, 'uPe'), + uLonCenter: gl.getUniformLocation(prog, 'uLonCenter'), + uLimbZ: gl.getUniformLocation(prog, 'uLimbZ'), + }; + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + return { + draw(cx, cy, r, rotY, width, height) { + gl.viewport(0, 0, width, height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.useProgram(prog); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.enableVertexAttribArray(loc.aPos); + gl.vertexAttribPointer(loc.aPos, 2, gl.FLOAT, false, 0, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, mercTex); + gl.uniform1i(loc.uMerc, 0); + gl.uniform3f(loc.uDisk, cx, cy, r); + gl.uniform1f(loc.uRotY, rotY); + gl.uniform1f(loc.uLatf, lz.latf); + gl.uniform1f(loc.uLngf, lz.lngf); + gl.uniform1f(loc.uZy, lz.zy); + gl.uniform1f(loc.uZx, lz.zx); + gl.uniform1f(loc.uPp, lz.pp); + gl.uniform1f(loc.uPe, lz.pe); + gl.uniform1f(loc.uLonCenter, opts.lonCenter); + gl.uniform1f(loc.uLimbZ, 0.002); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + }, + destroy() { + gl.deleteTexture(mercTex); + gl.deleteBuffer(buf); + gl.deleteProgram(prog); + }, + }; +} diff --git a/assets/hub-globe.js b/assets/hub-globe.js index daf077f..bf59749 100644 --- a/assets/hub-globe.js +++ b/assets/hub-globe.js @@ -13,13 +13,25 @@ * 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, …). + * ?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). */ @@ -33,6 +45,96 @@ const CONTINENT_INNER_SY = 1.2408477842003853; 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)) { @@ -53,14 +155,24 @@ function resolveLandZoom(raw) { }; } -/** URL ?ax=N — auto-spin speed multiplier (default 1). */ +/** URL ?ax=N — auto-spin speed multiplier (landing default 5). */ function resolveSpinFactor(raw) { if (typeof raw !== 'number' || !isFinite(raw)) { - return 1; + 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). @@ -110,13 +222,26 @@ const HUB_EW_PARALLELS = [ { cx: 720, cy: 728, rx: 260, ry: 39, bright: false }, ]; -/** Same SVG — N–S meridians (line + quadratic beziers). */ +/** 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++) { @@ -321,6 +446,24 @@ function parallelStyle(bright, scale, z, limbZ) { }; } +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))); @@ -334,6 +477,29 @@ function meridianStyle(prime, scale, z, limbZ) { }; } +/** 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; @@ -675,10 +841,8 @@ function drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par) { } const ecx = cx + ((xMin + xMax) / 2) * r; const ecy = cy - Math.sin(par.lat) * r; - const rgb = par.bright ? '219,231,246' : '231,236,243'; - const alpha = par.bright ? 0.55 + 0.45 * t : 0.35 + 0.5 * t; const layoutScale = r / GLOBE_VIEW.r; - const lineW = Math.max(par.bright ? 2.5 : 2, layoutScale * 0.0075) * (0.55 + 0.45 * t); + const stroke = gridLineStrokeStyle(par.bright, layoutScale, t); ctx.save(); ctx.beginPath(); @@ -686,8 +850,8 @@ function drawParallelScreenEllipse(ctx, cx, cy, r, rotY, par) { ctx.clip(); ctx.beginPath(); ctx.ellipse(ecx, ecy, rx, ry, 0, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(' + rgb + ',' + alpha.toFixed(3) + ')'; - ctx.lineWidth = lineW; + ctx.strokeStyle = 'rgba(' + stroke.rgb + ',' + stroke.alpha.toFixed(3) + ')'; + ctx.lineWidth = stroke.lineW; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); @@ -774,6 +938,24 @@ function drawSphereGridScreenStrip(ctx, cx, cy, r, rotY, layoutScale) { 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); @@ -919,10 +1101,14 @@ function drawGlobeGrid(ctx, cx, cy, r, rotY, layoutScale, renderVersion) { drawSphereGrid(ctx, cx, cy, r, rotY, layoutScale, { parallelsOnly: false }); return; } - if (renderVersion === 10 || renderVersion === 11) { + 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; @@ -930,7 +1116,115 @@ function drawGlobeGrid(ctx, cx, cy, r, rotY, layoutScale, renderVersion) { 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(); @@ -1138,45 +1432,111 @@ function sampleLandRgbaMercator(mercFlat, tex) { } /** globe3d=11 — geographic mercator wrap; semi-transparent fill + contrast borders. */ -function sampleLandVisibleHemisphere(mercFlat, tex, landZoom) { - if (tex.z <= LIMB_Z) { - return null; +/** 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; + } } - const flat = sampleMercFlatFromTex(mercFlat, tex, landZoom); - let kind = classifyHubContinentPixel(flat[0], flat[1], flat[2], flat[3]); - if (!kind) { - 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; + 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; } } } - 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]; + return { w: w, h: h, kind: kind, rgb: rgb }; } -function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { +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)); @@ -1187,13 +1547,123 @@ function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { 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 dy = y0; dy <= y1; dy++) { - for (let dx = x0; dx <= x1; dx++) { + 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) { @@ -1207,7 +1677,7 @@ function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { 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); + const rgba = fn(tex, nz); px[p++] = rgba[0]; px[p++] = rgba[1]; px[p++] = rgba[2]; @@ -1215,45 +1685,58 @@ function fillDiskRegion(ctx, cx, cy, r, rotY, fn) { } } - return { out, x0, y0 }; + return { out: out, x0: x0, y0: y0, stride: stride, w: w, h: h }; } -function blitLayer(ctx, cx, cy, r, layer) { +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; + 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); } - 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.drawImage(scratch, x0, y0, dw, dh); ctx.restore(); + return scratch; } function drawRim(ctx, globeImg, cx, cy, r, rim, layoutScale) { @@ -1276,13 +1759,40 @@ 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 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 canvas = document.createElement('canvas'); - canvas.className = 'hub-globe-canvas'; - canvas.setAttribute('aria-hidden', 'true'); - stage.appendChild(canvas); + 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), @@ -1301,7 +1811,9 @@ export async function mountHubGlobe(stage, opts) { ]); } catch (e) { console.warn('HubGlobe: asset load failed', e); - canvas.remove(); + canvasList.forEach(function (c) { + c.remove(); + }); return null; } @@ -1310,6 +1822,42 @@ export async function mountHubGlobe(stage, opts) { 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; @@ -1318,91 +1866,218 @@ export async function mountHubGlobe(stage, opts) { 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: true }); + 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 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 }; + const layout = applyCanvasGeometry(canvas); + if (bodyCanvas) { + applyCanvasGeometry(bodyCanvas); + } + if (landCanvas) { + applyCanvasGeometry(landCanvas); + } + invalidateLayerCaches(); + return layout; }; let layout = resize(); - window.addEventListener('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 { rect, dpr, px } = layout; - const cx = (rect.cx - rect.left) * dpr; - const cy = (rect.cy - rect.top) * dpr; + 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; - ctx.clearRect(0, 0, px, px); - blitLayer( - ctx, - cx, - cy, - r, - fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { - if (renderVersion >= 3) { - return sampleOceanSolid(); + 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); } - 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]; - }) - ); - } else if (renderVersion >= 11) { - blitLayer( - ctx, - cx, - cy, - r, - fillDiskRegion(ctx, cx, cy, r, rotation, function (tex) { - const l = sampleLandVisibleHemisphere(mercFlat, tex, landZoom); - return l || [0, 0, 0, 0]; - }) - ); + 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); - 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 (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) => { @@ -1426,6 +2101,7 @@ export async function mountHubGlobe(stage, opts) { } lastPointerX = ev.clientX; lastPointerTime = now; + scheduleRender(now); ev.preventDefault(); }; @@ -1444,7 +2120,7 @@ export async function mountHubGlobe(stage, opts) { canvas.addEventListener('pointercancel', endDrag); const tick = (now) => { - raf = requestAnimationFrame(tick); + tickActive = false; const dt = Math.min(0.05, (now - lastFrame) / 1000); lastFrame = now; @@ -1459,25 +2135,90 @@ export async function mountHubGlobe(stage, opts) { rotation += angularVelocity * dt; } - render(); + if (document.hidden || !heroVisible) { + return; + } + + const minInterval = dragging ? 0 : spinFrameMs; + if (now - lastRenderAt < minInterval) { + startTick(); + return; + } + scheduleRender(now); + startTick(); }; - raf = requestAnimationFrame(tick); + 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 !== 1) { + 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' + ? 'ew-sphere-ellipse-land-lit-world-mer' : renderVersion === 10 ? 'ew-sphere-ellipse' : renderVersion === 9 @@ -1498,9 +2239,21 @@ export async function mountHubGlobe(stage, opts) { return { destroy() { - cancelAnimationFrame(raf); - window.removeEventListener('resize', resize); - canvas.remove(); + 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; @@ -1510,6 +2263,25 @@ export async function mountHubGlobe(stage, opts) { 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; }, }; } diff --git a/assets/hub-landing.js b/assets/hub-landing.js index 10b76fc..4702499 100644 --- a/assets/hub-landing.js +++ b/assets/hub-landing.js @@ -108,7 +108,7 @@ document.body.addEventListener('click', function (ev) { var t = ev.target.closest('[data-goto-page]'); if (!t) return; - if (t.tagName === 'A' && t.getAttribute('href') === '#') { + if (t.tagName === 'A') { ev.preventDefault(); } setActivePage(Number(t.getAttribute('data-goto-page'))); diff --git a/hub-landing.css b/hub-landing.css index 91f5979..c3c137a 100644 --- a/hub-landing.css +++ b/hub-landing.css @@ -5,12 +5,32 @@ --landing-footer-h: 40px; --landing-left-w: 56px; --landing-rail-w: 44px; + /* Clock-widget derived brick chrome */ + --landing-brick-bg: rgba(18, 24, 34, 0.88); + --landing-brick-border: rgba(255, 255, 255, 0.72); + --landing-brick-shadow: 0 10px 30px rgba(0, 0, 0, 0.48); + --landing-brick-radius: 18px; + --landing-brick-border-hover: rgba(165, 225, 255, 0.98); + --landing-brick-glow-ring: rgba(140, 210, 255, 0.55); + --landing-brick-glow-near: rgba(120, 200, 255, 0.52); + --landing-brick-glow-far: rgba(90, 175, 255, 0.32); + --landing-brick-glow-inset: rgba(120, 200, 255, 0.07); width: 100%; max-width: 100%; height: 100vh; height: 100dvh; overflow: hidden; } +[data-theme="light"] .hub-landing { + --landing-brick-bg: rgba(255, 255, 255, 0.92); + --landing-brick-border: rgba(180, 200, 224, 0.88); + --landing-brick-shadow: 0 10px 28px rgba(26, 35, 50, 0.14); + --landing-brick-border-hover: rgba(61, 139, 253, 0.82); + --landing-brick-glow-ring: rgba(61, 139, 253, 0.45); + --landing-brick-glow-near: rgba(61, 139, 253, 0.28); + --landing-brick-glow-far: rgba(61, 139, 253, 0.16); + --landing-brick-glow-inset: rgba(61, 139, 253, 0.05); +} /* (1) Top header */ .landing-top { @@ -227,15 +247,94 @@ padding: 8px 4px; } -.landing-page-inner.card.card--lift { - padding: 28px 32px; - background: rgba(26, 35, 50, 0.78); - backdrop-filter: blur(8px); - border: 1px solid rgba(45, 58, 79, 0.85); - box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35), 0 0 32px rgba(61, 139, 253, 0.08); +/* Clock-widget glow + border — all landing bricks/panels */ +.hub-landing .landing-page-inner.card.card--lift, +.hub-landing .landing-feature-grid .card, +.hub-landing .landing-price-card, +.hub-landing .landing-gallery__tile, +.hub-landing .landing-download-card, +.hub-landing .landing-docs-grid .card, +.hub-landing .hub-docs-panel.card { + background: var(--landing-brick-bg); + backdrop-filter: blur(1.5px); + border: 1px solid var(--landing-brick-border); + border-radius: var(--landing-brick-radius); + box-shadow: var(--landing-brick-shadow); + transition: + transform 0.22s ease, + box-shadow 0.28s ease, + border-color 0.28s ease, + filter 0.22s ease; } -[data-theme="light"] .landing-page-inner.card.card--lift { - background: rgba(255, 255, 255, 0.88); + +.hub-landing .landing-page-inner.card.card--lift { + padding: 28px 32px; +} + +.hub-landing .landing-page-inner.card.card--lift:hover, +.hub-landing .landing-feature-grid .card:hover, +.hub-landing .landing-price-card:hover, +.hub-landing .landing-gallery__tile:hover, +.hub-landing .landing-download-card:hover, +.hub-landing .landing-docs-grid .card:hover, +.hub-landing .hub-docs-panel.card:hover, +.hub-landing .landing-feature-grid .card:focus-within, +.hub-landing .landing-download-card:focus-visible, +.hub-landing .landing-docs-grid .card:focus-within { + cursor: pointer; + border-color: var(--landing-brick-border-hover); + box-shadow: + 0 16px 42px rgba(0, 0, 0, 0.62), + 0 0 0 1px var(--landing-brick-glow-ring), + 0 0 22px var(--landing-brick-glow-near), + 0 0 44px var(--landing-brick-glow-far), + inset 0 0 14px var(--landing-brick-glow-inset); + filter: saturate(1.06); +} + +[data-theme="light"] .hub-landing .landing-page-inner.card.card--lift:hover, +[data-theme="light"] .hub-landing .landing-feature-grid .card:hover, +[data-theme="light"] .hub-landing .landing-price-card:hover, +[data-theme="light"] .hub-landing .landing-gallery__tile:hover, +[data-theme="light"] .hub-landing .landing-download-card:hover, +[data-theme="light"] .hub-landing .landing-docs-grid .card:hover, +[data-theme="light"] .hub-landing .hub-docs-panel.card:hover, +[data-theme="light"] .hub-landing .landing-feature-grid .card:focus-within, +[data-theme="light"] .hub-landing .landing-download-card:focus-visible, +[data-theme="light"] .hub-landing .landing-docs-grid .card:focus-within { + box-shadow: + 0 14px 36px rgba(26, 35, 50, 0.16), + 0 0 0 1px var(--landing-brick-glow-ring), + 0 0 20px var(--landing-brick-glow-near), + 0 0 36px var(--landing-brick-glow-far), + inset 0 0 12px var(--landing-brick-glow-inset); +} + +.hub-landing .landing-page-inner.card.card--lift:hover { + transform: scale(1.012); +} + +.hub-landing .landing-feature-grid .card:hover, +.hub-landing .landing-price-card:hover, +.hub-landing .landing-docs-grid .card:hover, +.hub-landing .landing-download-card:hover, +.hub-landing .landing-download-card:focus-visible { + transform: translateY(-3px) scale(1.02); +} + +.hub-landing .landing-download-card.btn.btn-primary { + color: var(--text); + background: var(--landing-brick-bg); +} + +.hub-landing .landing-download-card.btn.btn-primary:hover, +.hub-landing .landing-download-card.btn.btn-primary:focus-visible { + color: var(--text); + background: var(--landing-brick-bg); +} + +.hub-landing .hub-docs-panel.card:hover { + transform: translate(-50%, -50%); } .landing-page h2 { @@ -263,8 +362,6 @@ display: block; width: 100%; aspect-ratio: 4 / 3; - border-radius: 10px; - border: 1px solid var(--border); background: linear-gradient(145deg, rgba(61, 139, 253, 0.22), rgba(12, 24, 48, 0.85)); } .landing-gallery__tile--network { @@ -289,11 +386,27 @@ gap: 14px; margin-top: 20px; } -.landing-feature-grid .card { +.hub-landing .landing-feature-grid .card { padding: 16px; margin: 0; } +.hub-landing .landing-feature-card { + display: block; + text-decoration: none; + color: inherit; +} + +.hub-landing .landing-feature-card:hover, +.hub-landing .landing-feature-card:focus-visible { + color: inherit; + text-decoration: none; +} + +.hub-landing .landing-feature-card h3 { + margin: 0 0 8px; +} + .landing-price-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); @@ -302,9 +415,6 @@ } .landing-price-card { padding: 20px; - border-radius: 12px; - border: 1px solid var(--border); - background: var(--surface2); } .landing-price-card h3 { margin: 0 0 8px; } .landing-price-card .price { @@ -346,6 +456,14 @@ } .landing-page--hero h2 { font-size: clamp(2rem, 5vw, 3rem); + color: #fff; + -webkit-text-stroke: 0.65px #000; + paint-order: stroke fill; +} +.landing-page--hero .landing-lead { + color: #fff; + -webkit-text-stroke: 0.45px #000; + paint-order: stroke fill; } /* (4) Right page rail — hidden until handle hover/drag */ @@ -504,7 +622,6 @@ width: min(220px, 100%); min-height: 168px; padding: 24px 20px 20px; - border-radius: 18px; font-size: 1rem; font-weight: 600; text-align: center; @@ -514,8 +631,6 @@ align-items: center; justify-content: center; gap: 16px; - transition: box-shadow 0.2s ease, transform 0.15s ease; - border: 1px solid rgba(61, 139, 253, 0.35); } .landing-download-card__icon { @@ -541,12 +656,6 @@ letter-spacing: 0.01em; } -.landing-download-card:hover, -.landing-download-card:focus-visible { - transform: translateY(-2px); - box-shadow: 0 0 30px rgba(61, 139, 253, 0.45); -} - /* Legacy pill buttons (documentation page, resources) */ .landing-download-btn { min-width: 200px; diff --git a/hub-logo-layers.css b/hub-logo-layers.css index de3d6f0..9cc3a96 100644 --- a/hub-logo-layers.css +++ b/hub-logo-layers.css @@ -36,7 +36,30 @@ border-radius: 50%; } -.hub-globe-canvas.is-dragging { +/* v11 fog halo extends outside the disk — square canvas, no circular CSS clip */ +.hub-globe-canvas--halo { + border-radius: 0; +} + +.hub-globe-canvas--body, +.hub-globe-canvas--land { + pointer-events: none; +} + +.hub-globe-canvas--overlay { + z-index: 2; +} + +.hub-globe-canvas--land { + z-index: 1; +} + +.hub-globe-canvas--body { + z-index: 0; +} + +.hub-globe-canvas.is-dragging, +.hub-globe-canvas--overlay.is-dragging { pointer-events: auto; cursor: grabbing; } diff --git a/index.php b/index.php index a993710..e63b3ef 100644 --- a/index.php +++ b/index.php @@ -40,8 +40,8 @@ function hub_h(string $s): string { - - + + @@ -187,7 +187,10 @@ function hub_h(string $s): string { var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover')); var logoEnabled = !!(stage && supportsSvg && supportsLayout); - var logoBuild = '20260704globe31'; + var logoBuild = '20260704globe56'; + + var GLOBE3D_DEFAULT = 11; + var GLOBE_SPIN_DEFAULT = 5; function hubAsset(rel) { var link = document.querySelector('link[href*="hub.css"]'); @@ -254,8 +257,8 @@ function hub_h(string $s): string { } var globe3dRaw = params.get('globe3d'); - var globe3dVersion = globe3dRaw === null || globe3dRaw === '' ? 1 : parseInt(globe3dRaw, 10); - if (!isFinite(globe3dVersion)) globe3dVersion = 1; + var globe3dVersion = globe3dRaw === null || globe3dRaw === '' ? GLOBE3D_DEFAULT : parseInt(globe3dRaw, 10); + if (!isFinite(globe3dVersion)) globe3dVersion = GLOBE3D_DEFAULT; var globeLandZoom = {}; ['latf', 'lngf', 'zy', 'zx', 'pp', 'pe'].forEach(function (key) { var raw = params.get(key); @@ -269,11 +272,68 @@ function hub_h(string $s): string { if (isFinite(fLegacy)) globeLandZoom.latf = fLegacy; } var globeAxRaw = params.get('ax'); - var globeSpinFactor = 1; + var globeSpinFactor = GLOBE_SPIN_DEFAULT; if (globeAxRaw !== null && globeAxRaw !== '') { var axParsed = parseFloat(globeAxRaw); if (isFinite(axParsed)) globeSpinFactor = axParsed; } + var globeContinentStyle = {}; + var globeSunlightStyle = {}; + var globeFogStyle = {}; + var globePerf = {}; + if (globe3dVersion >= 11) { + var continentParamMap = { + cfa: 'fillAlpha', + cba: 'borderAlpha', + cbr: 'borderBoostR', + cbg: 'borderBoostG', + cbb: 'borderBoostB', + ceps: 'coastEps' + }; + Object.keys(continentParamMap).forEach(function (key) { + var raw = params.get(key); + if (raw === null || raw === '') return; + var n = parseFloat(raw); + if (isFinite(n)) globeContinentStyle[continentParamMap[key]] = n; + }); + var sunlightParamMap = { + slx: 'lx', + sly: 'ly', + slx2: 'lx2', + sly2: 'ly2', + sld: 'dir', + sll: 'span', + sli: 'intensity', + slhi: 'highlight', + slsh: 'shadow' + }; + Object.keys(sunlightParamMap).forEach(function (key) { + var raw = params.get(key); + if (raw === null || raw === '') return; + var n = parseFloat(raw); + if (isFinite(n)) globeSunlightStyle[sunlightParamMap[key]] = n; + }); + var fogParamMap = { fogi: 'intensity', fogr: 'outer', fogin: 'inner' }; + Object.keys(fogParamMap).forEach(function (key) { + var raw = params.get(key); + if (raw === null || raw === '') return; + var n = parseFloat(raw); + if (isFinite(n)) globeFogStyle[fogParamMap[key]] = n; + }); + var globeFpsRaw = params.get('globeFps'); + if (globeFpsRaw !== null && globeFpsRaw !== '') { + var fpsParsed = parseFloat(globeFpsRaw); + if (isFinite(fpsParsed)) globePerf.spinFps = fpsParsed; + } + var globeDprRaw = params.get('globeDpr'); + if (globeDprRaw !== null && globeDprRaw !== '') { + var dprParsed = parseFloat(globeDprRaw); + if (isFinite(dprParsed)) globePerf.dprMax = dprParsed; + } + var globeGpuRaw = params.get('globeGpu'); + if (globeGpuRaw === '0') globePerf.useGpu = false; + if (params.get('globeProfile') === '1') globePerf.profile = true; + } var useGlobe3d = logoEnabled && globe3dVersion !== 0; var globe3dLayers = { globe: true, continents: true, gridOverlay: true }; @@ -321,9 +381,21 @@ function hub_h(string $s): string { if (Object.keys(globeLandZoom).length > 0) { globeOpts.landZoom = globeLandZoom; } - if (globeSpinFactor !== 1) { + if (globeSpinFactor !== GLOBE_SPIN_DEFAULT) { globeOpts.spinFactor = globeSpinFactor; } + if (Object.keys(globeContinentStyle).length > 0) { + globeOpts.continentStyle = globeContinentStyle; + } + if (Object.keys(globeSunlightStyle).length > 0) { + globeOpts.sunlightStyle = globeSunlightStyle; + } + if (Object.keys(globeFogStyle).length > 0) { + globeOpts.fogStyle = globeFogStyle; + } + if (Object.keys(globePerf).length > 0) { + globeOpts.globePerf = globePerf; + } var globeModuleUrl = new URL('assets/hub-globe.js', window.location.href); globeModuleUrl.searchParams.set('v', logoBuild); globeModuleUrl.searchParams.set('globe3d', String(globe3dVersion)); diff --git a/landing-pages.inc.php b/landing-pages.inc.php index 39e89e6..98f426b 100644 --- a/landing-pages.inc.php +++ b/landing-pages.inc.php @@ -12,18 +12,18 @@
Mirror Android screens with low latency, native codecs, and operator-grade tooling.
Wi‑Fi and USB transport with adaptive bitrate and session metrics.
-Ingest, triage tickets, and correlate fingerprints across releases.
-Docker CI for APK baking, OTA artifacts, and pipeline history.
-WebRTC, Opus/Speex, WireGuard remote access, PHP consoles, and Docker CI pipelines.
Low-latency mirror paths
Native codec validation
Remote device sessions
Crash + ticket console
Low-latency mirror paths
Native codec validation
Remote device sessions
Crash + ticket console