@project-skymap/library 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1411,16 +1411,111 @@ function createEngine({
1411
1411
  function getSceneDebug() {
1412
1412
  return currentConfig?.debug?.sceneMechanics;
1413
1413
  }
1414
+ function getFreezeBand() {
1415
+ const dbg = getSceneDebug();
1416
+ const startRaw = dbg?.freezeBandStartFov ?? ENGINE_CONFIG.freezeBandStartFov;
1417
+ const endRaw = dbg?.freezeBandEndFov ?? ENGINE_CONFIG.freezeBandEndFov;
1418
+ const start2 = Math.min(startRaw, endRaw);
1419
+ const end = Math.max(startRaw, endRaw);
1420
+ return { start: start2, end };
1421
+ }
1422
+ function isInTransitionFreezeBand(fov) {
1423
+ const band = getFreezeBand();
1424
+ return fov >= band.start && fov <= band.end;
1425
+ }
1426
+ function getZenithBiasStartFov() {
1427
+ return getSceneDebug()?.zenithBiasStartFov ?? ENGINE_CONFIG.zenithBiasStartFov;
1428
+ }
1429
+ function getVerticalPanDampConfig() {
1430
+ const dbg = getSceneDebug();
1431
+ const fovStartRaw = dbg?.verticalPanDampStartFov ?? ENGINE_CONFIG.verticalPanDampStartFov;
1432
+ const fovEndRaw = dbg?.verticalPanDampEndFov ?? ENGINE_CONFIG.verticalPanDampEndFov;
1433
+ const latStartRaw = dbg?.verticalPanDampLatStartDeg ?? ENGINE_CONFIG.verticalPanDampLatStartDeg;
1434
+ const latEndRaw = dbg?.verticalPanDampLatEndDeg ?? ENGINE_CONFIG.verticalPanDampLatEndDeg;
1435
+ return {
1436
+ fovStart: Math.min(fovStartRaw, fovEndRaw),
1437
+ fovEnd: Math.max(fovStartRaw, fovEndRaw),
1438
+ latStartDeg: Math.min(latStartRaw, latEndRaw),
1439
+ latEndDeg: Math.max(latStartRaw, latEndRaw)
1440
+ };
1441
+ }
1442
+ function getVerticalPanFactor(fov, lat) {
1443
+ if (zenithProjectionLockActive) return 0;
1444
+ const cfg = getVerticalPanDampConfig();
1445
+ const fovT = THREE6.MathUtils.smoothstep(fov, cfg.fovStart, cfg.fovEnd);
1446
+ const zenithT = THREE6.MathUtils.smoothstep(
1447
+ Math.max(lat, 0),
1448
+ THREE6.MathUtils.degToRad(cfg.latStartDeg),
1449
+ THREE6.MathUtils.degToRad(cfg.latEndDeg)
1450
+ );
1451
+ const lock = Math.max(fovT * 0.65, fovT * zenithT);
1452
+ return THREE6.MathUtils.clamp(1 - lock, 0, 1);
1453
+ }
1454
+ function getMovementMassFactor(fov, wideFovFactor = ENGINE_CONFIG.movementMassWideFov) {
1455
+ const t = THREE6.MathUtils.smoothstep(fov, 24, 96);
1456
+ return THREE6.MathUtils.lerp(1, wideFovFactor, t);
1457
+ }
1458
+ function compressInputDelta(delta) {
1459
+ const absDelta = Math.abs(delta);
1460
+ if (absDelta < 1e-4) return 0;
1461
+ return Math.sign(delta) * (absDelta / (1 + absDelta * ENGINE_CONFIG.inputCompression));
1462
+ }
1414
1463
  const constellationLayer = new ConstellationArtworkLayer(scene);
1415
1464
  function mix(a, b, t) {
1416
1465
  return a * (1 - t) + b * t;
1417
1466
  }
1418
1467
  let currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
1468
+ let zenithProjectionLockActive = false;
1469
+ function getZenithLockBlendThresholds() {
1470
+ const enterRaw = ENGINE_CONFIG.zenithLockBlendEnter;
1471
+ const exitRaw = ENGINE_CONFIG.zenithLockBlendExit;
1472
+ return {
1473
+ enter: Math.max(0, Math.min(1, enterRaw)),
1474
+ exit: Math.max(0, Math.min(1, Math.min(exitRaw, enterRaw)))
1475
+ };
1476
+ }
1477
+ function getZenithLockLat() {
1478
+ return Math.PI / 2 - 1e-3;
1479
+ }
1480
+ function getBlendForZenithControl() {
1481
+ if (currentProjection instanceof BlendedProjection) return currentProjection.getBlend();
1482
+ return 0;
1483
+ }
1484
+ function applyZenithAutoCenter() {
1485
+ const zenithLat = getZenithLockLat();
1486
+ const blend = getBlendForZenithControl();
1487
+ let pullT = THREE6.MathUtils.smoothstep(
1488
+ blend,
1489
+ ENGINE_CONFIG.zenithAutoCenterBlendStart,
1490
+ ENGINE_CONFIG.zenithAutoCenterBlendEnd
1491
+ );
1492
+ if (zenithProjectionLockActive) pullT = 1;
1493
+ if (pullT <= 1e-4) return;
1494
+ const pullLerp = THREE6.MathUtils.lerp(
1495
+ ENGINE_CONFIG.zenithAutoCenterMinLerp,
1496
+ ENGINE_CONFIG.zenithAutoCenterMaxLerp,
1497
+ pullT
1498
+ );
1499
+ state.lat = THREE6.MathUtils.lerp(state.lat, zenithLat, pullLerp);
1500
+ state.targetLat = THREE6.MathUtils.lerp(state.targetLat, zenithLat, Math.min(1, pullLerp * 1.15));
1501
+ state.velocityY *= 1 - 0.85 * pullT;
1502
+ if (zenithProjectionLockActive && Math.abs(state.lat - zenithLat) < 25e-5) {
1503
+ state.lat = zenithLat;
1504
+ state.targetLat = zenithLat;
1505
+ state.velocityY = 0;
1506
+ }
1507
+ }
1419
1508
  function syncProjectionState() {
1420
1509
  if (currentProjection instanceof BlendedProjection) {
1421
1510
  currentProjection.setFov(state.fov);
1422
1511
  currentProjection.setBlendOverride(getSceneDebug()?.projectionBlendOverride ?? null);
1423
1512
  globalUniforms.uBlend.value = currentProjection.getBlend();
1513
+ const blend = currentProjection.getBlend();
1514
+ const th = getZenithLockBlendThresholds();
1515
+ if (!zenithProjectionLockActive && blend >= th.enter) zenithProjectionLockActive = true;
1516
+ else if (zenithProjectionLockActive && blend <= th.exit) zenithProjectionLockActive = false;
1517
+ } else {
1518
+ zenithProjectionLockActive = false;
1424
1519
  }
1425
1520
  globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
1426
1521
  }
@@ -1677,8 +1772,13 @@ function createEngine({
1677
1772
  }
1678
1773
  const flatten = groundMaterial?.uniforms?.uZenithFlatten?.value;
1679
1774
  const blend = currentProjection instanceof BlendedProjection ? currentProjection.getBlend() : -1;
1775
+ const freeze = isInTransitionFreezeBand(state.fov) ? 1 : 0;
1776
+ const zenithBiasStart = getZenithBiasStartFov();
1777
+ const vPanCfg = getVerticalPanDampConfig();
1778
+ const vPan = getVerticalPanFactor(state.fov, state.lat);
1779
+ const moveMass = getMovementMassFactor(state.fov);
1680
1780
  console.debug(
1681
- `[HorizonDiag] fov=${state.fov.toFixed(1)} latDeg=${THREE6.MathUtils.radToDeg(state.lat).toFixed(1)} mode=${activeHorizonProfile.mode} blend=${blend.toFixed(3)} flatten=${Number(flatten ?? 0).toFixed(3)} drops=${dropCount} bins=${JSON.stringify(compact)}`
1781
+ `[HorizonDiag] fov=${state.fov.toFixed(1)} latDeg=${THREE6.MathUtils.radToDeg(state.lat).toFixed(1)} mode=${activeHorizonProfile.mode} blend=${blend.toFixed(3)} freeze=${freeze} zLock=${zenithProjectionLockActive ? 1 : 0} biasStart=${zenithBiasStart.toFixed(1)} vPan=${vPan.toFixed(3)} moveMass=${moveMass.toFixed(3)} vPanFov=${vPanCfg.fovStart.toFixed(1)}-${vPanCfg.fovEnd.toFixed(1)} vPanLat=${vPanCfg.latStartDeg.toFixed(1)}-${vPanCfg.latEndDeg.toFixed(1)} flatten=${Number(flatten ?? 0).toFixed(3)} drops=${dropCount} bins=${JSON.stringify(compact)}`
1682
1782
  );
1683
1783
  }
1684
1784
  function createGround() {
@@ -1827,9 +1927,6 @@ function createEngine({
1827
1927
  let sunDiscMesh = null;
1828
1928
  let sunHaloMesh = null;
1829
1929
  let milkyWayMesh = null;
1830
- let editHoverMesh = null;
1831
- let editHoverTargetPos = null;
1832
- let editDropFlash = 0;
1833
1930
  function createSkyBackground() {
1834
1931
  const geo = new THREE6.SphereGeometry(2400, 32, 32);
1835
1932
  const mat = createSmartMaterial({
@@ -2381,7 +2478,8 @@ function createEngine({
2381
2478
  uTime: globalUniforms.uTime,
2382
2479
  uBackdropGain: { value: 1 },
2383
2480
  uBackdropEnergy: { value: 2.2 },
2384
- uBackdropSizeExp: { value: 0.9 }
2481
+ uBackdropSizeExp: { value: 0.9 },
2482
+ uRevealZoom: { value: 0 }
2385
2483
  },
2386
2484
  vertexShaderBody: `
2387
2485
  attribute float size;
@@ -2395,6 +2493,7 @@ function createEngine({
2395
2493
  uniform float uBackdropGain;
2396
2494
  uniform float uBackdropEnergy;
2397
2495
  uniform float uBackdropSizeExp;
2496
+ uniform float uRevealZoom;
2398
2497
 
2399
2498
  void main() {
2400
2499
  vec3 nPos = normalize(position);
@@ -2410,7 +2509,12 @@ function createEngine({
2410
2509
  float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
2411
2510
  float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
2412
2511
 
2413
- vColor = color * uBackdropEnergy * extinction * horizonFade * scintillation * uBackdropGain;
2512
+ // Backdrop appears latest \u2014 fully hidden at wide FOV, emerges when zoomed in.
2513
+ // Thresholds come from ZOOM_REVEAL_CONFIG (baked at startup).
2514
+ float mappedZoom = pow(uRevealZoom, ${ZOOM_REVEAL_CONFIG.zoomCurveExp});
2515
+ float backdropReveal = smoothstep(${ZOOM_REVEAL_CONFIG.backdropRevealStart}, ${ZOOM_REVEAL_CONFIG.backdropRevealEnd}, mappedZoom);
2516
+
2517
+ vColor = color * uBackdropEnergy * extinction * horizonFade * scintillation * uBackdropGain * backdropReveal;
2414
2518
 
2415
2519
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
2416
2520
  gl_Position = smartProject(mvPosition);
@@ -2450,55 +2554,6 @@ function createEngine({
2450
2554
  points.frustumCulled = false;
2451
2555
  backdropGroup.add(points);
2452
2556
  }
2453
- function createEditHoverRing() {
2454
- const geo = new THREE6.PlaneGeometry(1, 1);
2455
- const mat = createSmartMaterial({
2456
- uniforms: {
2457
- uRingSize: { value: 0.06 },
2458
- uRingAlpha: { value: 0 },
2459
- uRingColor: { value: new THREE6.Color(0.55, 0.88, 1) }
2460
- },
2461
- vertexShaderBody: `
2462
- uniform float uRingSize;
2463
- varying vec2 vUv;
2464
- void main() {
2465
- vUv = uv;
2466
- vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2467
- vec4 proj = smartProject(mvPos);
2468
- if (proj.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2469
- vec2 offset = position.xy * uRingSize * uScale;
2470
- proj.xy += offset / vec2(uAspect, 1.0);
2471
- vScreenPos = proj.xy / proj.w;
2472
- gl_Position = proj;
2473
- }
2474
- `,
2475
- fragmentShader: `
2476
- varying vec2 vUv;
2477
- uniform float uRingAlpha;
2478
- uniform vec3 uRingColor;
2479
- void main() {
2480
- float alphaMask = getMaskAlpha();
2481
- if (alphaMask < 0.01) discard;
2482
- vec2 p = vUv * 2.0 - 1.0;
2483
- float d = length(p);
2484
- float ring = smoothstep(0.52, 0.62, d) * (1.0 - smoothstep(0.80, 0.92, d));
2485
- float glow = (1.0 - smoothstep(0.55, 0.98, d)) * 0.18;
2486
- float a = (ring + glow) * uRingAlpha * alphaMask;
2487
- if (a < 0.005) discard;
2488
- gl_FragColor = vec4(uRingColor * (ring * 1.2 + glow), a);
2489
- }
2490
- `,
2491
- transparent: true,
2492
- depthWrite: false,
2493
- depthTest: false,
2494
- side: THREE6.DoubleSide,
2495
- blending: THREE6.AdditiveBlending
2496
- });
2497
- editHoverMesh = new THREE6.Mesh(geo, mat);
2498
- editHoverMesh.renderOrder = 500;
2499
- editHoverMesh.frustumCulled = false;
2500
- scene.add(editHoverMesh);
2501
- }
2502
2557
  createSkyBackground();
2503
2558
  createGround();
2504
2559
  createAtmosphere();
@@ -2506,7 +2561,6 @@ function createEngine({
2506
2561
  createSun();
2507
2562
  createMilkyWay();
2508
2563
  createBackdropStars();
2509
- createEditHoverRing();
2510
2564
  const raycaster = new THREE6.Raycaster();
2511
2565
  raycaster.params.Points.threshold = 5;
2512
2566
  new THREE6.Vector2();
@@ -2613,7 +2667,7 @@ function createEngine({
2613
2667
  function getPosition(n) {
2614
2668
  if (currentConfig?.arrangement) {
2615
2669
  const arr = currentConfig.arrangement[n.id];
2616
- if (arr) {
2670
+ if (arr?.position) {
2617
2671
  const [px, py, pz] = arr.position;
2618
2672
  if (pz === 0) {
2619
2673
  const radius = currentConfig.layout?.radius ?? 2e3;
@@ -2762,6 +2816,7 @@ function createEngine({
2762
2816
  const starChapterIndices = [];
2763
2817
  const starTestamentIndices = [];
2764
2818
  const starDivisionIndices = [];
2819
+ const starRevealThresholds = [];
2765
2820
  const chapterLineCutById = /* @__PURE__ */ new Map();
2766
2821
  const chapterStarSizeById = /* @__PURE__ */ new Map();
2767
2822
  const chapterWeightNormById = /* @__PURE__ */ new Map();
@@ -2798,12 +2853,23 @@ function createEngine({
2798
2853
  } else if (minWeight === maxWeight) {
2799
2854
  maxWeight = minWeight + 1;
2800
2855
  }
2856
+ {
2857
+ const pctCap = THREE6.MathUtils.clamp(cfg.starSizeWeightPercentile ?? 0.95, 0.5, 1);
2858
+ const allWeights = [];
2859
+ for (const n of laidOut.nodes) {
2860
+ if (n.level === 3 && typeof n.weight === "number") allWeights.push(n.weight);
2861
+ }
2862
+ allWeights.sort((a, b) => a - b);
2863
+ const capIdx = Math.min(Math.floor(pctCap * allWeights.length), allWeights.length - 1);
2864
+ const cappedMax = allWeights[capIdx];
2865
+ if (cappedMax !== void 0 && cappedMax > minWeight) maxWeight = cappedMax;
2866
+ }
2801
2867
  for (const n of laidOut.nodes) {
2802
2868
  if (n.level === 3) {
2803
2869
  let baseSize = 3.5;
2804
2870
  let weightNorm = 0;
2805
2871
  if (typeof n.weight === "number") {
2806
- weightNorm = (n.weight - minWeight) / (maxWeight - minWeight);
2872
+ weightNorm = THREE6.MathUtils.clamp((n.weight - minWeight) / (maxWeight - minWeight), 0, 1);
2807
2873
  const sizeExp = cfg.starSizeExponent ?? 4;
2808
2874
  const sizeScale = cfg.starSizeScale ?? 6;
2809
2875
  baseSize = Math.pow(weightNorm, sizeExp) * 22 * sizeScale;
@@ -2828,6 +2894,14 @@ function createEngine({
2828
2894
  starIndexToId.push(n.id);
2829
2895
  const baseSize = chapterStarSizeById.get(n.id) ?? 3.5;
2830
2896
  starSizes.push(baseSize);
2897
+ {
2898
+ const wn = chapterWeightNormById.get(n.id) ?? 0;
2899
+ starRevealThresholds.push(THREE6.MathUtils.lerp(
2900
+ -ZOOM_REVEAL_CONFIG.chapterFeather,
2901
+ ZOOM_REVEAL_CONFIG.chapterRevealMax,
2902
+ 1 - wn
2903
+ ));
2904
+ }
2831
2905
  chapterLineCutById.set(
2832
2906
  n.id,
2833
2907
  THREE6.MathUtils.clamp(2.5 + baseSize * 0.45, 3, 40)
@@ -2978,6 +3052,7 @@ function createEngine({
2978
3052
  starGeo.setAttribute("chapterIndex", new THREE6.Float32BufferAttribute(starChapterIndices, 1));
2979
3053
  starGeo.setAttribute("testamentIndex", new THREE6.Float32BufferAttribute(starTestamentIndices, 1));
2980
3054
  starGeo.setAttribute("divisionIndex", new THREE6.Float32BufferAttribute(starDivisionIndices, 1));
3055
+ starGeo.setAttribute("revealThreshold", new THREE6.Float32BufferAttribute(starRevealThresholds, 1));
2981
3056
  const starMat = createSmartMaterial({
2982
3057
  uniforms: {
2983
3058
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -2995,19 +3070,22 @@ function createEngine({
2995
3070
  uFilterDivisionIndex: { value: -1 },
2996
3071
  uFilterBookIndex: { value: -1 },
2997
3072
  uFilterStrength: { value: 0 },
2998
- uFilterDimFactor: { value: 0.08 }
3073
+ uFilterDimFactor: { value: 0.08 },
3074
+ uRevealZoom: { value: 0 }
2999
3075
  },
3000
3076
  vertexShaderBody: `
3001
- attribute float size;
3002
- attribute vec3 color;
3077
+ attribute float size;
3078
+ attribute vec3 color;
3003
3079
  attribute float phase;
3004
3080
  attribute float bookIndex;
3005
3081
  attribute float chapterIndex;
3006
3082
  attribute float testamentIndex;
3007
3083
  attribute float divisionIndex;
3084
+ attribute float revealThreshold;
3008
3085
 
3009
3086
  varying vec3 vColor;
3010
3087
  varying float vSize;
3088
+ varying float vReveal;
3011
3089
  uniform float pixelRatio;
3012
3090
 
3013
3091
  uniform float uTime;
@@ -3024,6 +3102,7 @@ function createEngine({
3024
3102
  uniform float uFilterBookIndex;
3025
3103
  uniform float uFilterStrength;
3026
3104
  uniform float uFilterDimFactor;
3105
+ uniform float uRevealZoom;
3027
3106
 
3028
3107
  void main() {
3029
3108
  vec3 nPos = normalize(position);
@@ -3086,11 +3165,17 @@ function createEngine({
3086
3165
  float perceptualSize = pow(size, 0.7);
3087
3166
  gl_PointSize = clamp((perceptualSize * sizeBoost * 20.0) * uScale * pixelRatio * (2000.0 / length(mvPosition.xyz)) * horizonFade, 1.0, 600.0);
3088
3167
  vSize = gl_PointSize;
3168
+
3169
+ // Zoom-based reveal: faint stars hide at wide FOV, fade in as user zooms.
3170
+ // Exponent and feather baked from ZOOM_REVEAL_CONFIG at startup.
3171
+ float mappedZoom = pow(uRevealZoom, ${ZOOM_REVEAL_CONFIG.zoomCurveExp});
3172
+ vReveal = smoothstep(revealThreshold, revealThreshold + ${ZOOM_REVEAL_CONFIG.chapterFeather}, mappedZoom);
3089
3173
  }
3090
3174
  `,
3091
3175
  fragmentShader: `
3092
3176
  varying vec3 vColor;
3093
3177
  varying float vSize;
3178
+ varying float vReveal;
3094
3179
  void main() {
3095
3180
  vec2 coord = gl_PointCoord - vec2(0.5);
3096
3181
  float d = length(coord) * 2.0;
@@ -3119,7 +3204,8 @@ function createEngine({
3119
3204
  float spikeV = exp(-coord.x * coord.x * 180.0) * exp(-abs(coord.y) * 6.0);
3120
3205
  float spikes = (spikeH + spikeV) * 0.18 * spikeFactor;
3121
3206
 
3122
- gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, 1.0);
3207
+ // vReveal drives the additive contribution (AdditiveBlending uses SRC_ALPHA).
3208
+ gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, vReveal);
3123
3209
  }
3124
3210
  `,
3125
3211
  transparent: true,
@@ -3624,20 +3710,23 @@ function createEngine({
3624
3710
  constellationLayer.load(cfg.constellations, (id) => {
3625
3711
  if (cfg.arrangement && cfg.arrangement[id]) {
3626
3712
  const arr = cfg.arrangement[id];
3627
- if (arr.position[2] === 0) {
3628
- const x = arr.position[0];
3629
- const y = arr.position[1];
3630
- const radius = cfg.layout?.radius ?? 2e3;
3631
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3632
- const phi = Math.atan2(y, x);
3633
- const theta = r_norm * (Math.PI / 2);
3634
- return new THREE6.Vector3(
3635
- Math.sin(theta) * Math.cos(phi),
3636
- Math.cos(theta),
3637
- Math.sin(theta) * Math.sin(phi)
3638
- ).multiplyScalar(radius);
3713
+ const coords = arr.center ?? arr.position;
3714
+ if (coords) {
3715
+ if (coords[2] === 0) {
3716
+ const x = coords[0];
3717
+ const y = coords[1];
3718
+ const radius = cfg.layout?.radius ?? 2e3;
3719
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3720
+ const phi = Math.atan2(y, x);
3721
+ const theta = r_norm * (Math.PI / 2);
3722
+ return new THREE6.Vector3(
3723
+ Math.sin(theta) * Math.cos(phi),
3724
+ Math.cos(theta),
3725
+ Math.sin(theta) * Math.sin(phi)
3726
+ ).multiplyScalar(radius);
3727
+ }
3728
+ return new THREE6.Vector3(coords[0], coords[1], coords[2]);
3639
3729
  }
3640
- return new THREE6.Vector3(arr.position[0], arr.position[1], arr.position[2]);
3641
3730
  }
3642
3731
  return getLayoutPosition(id);
3643
3732
  }, getLayoutPosition);
@@ -3665,7 +3754,7 @@ function createEngine({
3665
3754
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
3666
3755
  }
3667
3756
  for (const item of constellationLayer.getItems()) {
3668
- arr[item.config.id] = { position: [item.center.x, item.center.y, item.center.z] };
3757
+ arr[item.config.id] = { center: [item.center.x, item.center.y, item.center.z] };
3669
3758
  }
3670
3759
  Object.assign(arr, state.tempArrangement);
3671
3760
  return arr;
@@ -3788,29 +3877,65 @@ function createEngine({
3788
3877
  isMouseInWindow = false;
3789
3878
  edgeHoverStart = 0;
3790
3879
  }
3880
+ function screenSpacePickStar(mx, my, maxPx = 50) {
3881
+ if (!starPoints) return null;
3882
+ const attr = starPoints.geometry.attributes.position;
3883
+ const rect = renderer.domElement.getBoundingClientRect();
3884
+ const w = rect.width;
3885
+ const h = rect.height;
3886
+ const uScale = globalUniforms.uScale.value;
3887
+ const uAspect = globalUniforms.uAspect.value;
3888
+ let bestIdx = -1;
3889
+ let bestDist2 = maxPx * maxPx;
3890
+ const worldPos = new THREE6.Vector3();
3891
+ for (let i = 0; i < attr.count; i++) {
3892
+ worldPos.set(attr.getX(i), attr.getY(i), attr.getZ(i));
3893
+ const proj = smartProjectJS(worldPos);
3894
+ if (currentProjection.isClipped(proj.z)) continue;
3895
+ const sx = (proj.x * uScale / uAspect * 0.5 + 0.5) * w;
3896
+ const sy = (-(proj.y * uScale) * 0.5 + 0.5) * h;
3897
+ const dx = mx - sx;
3898
+ const dy = my - sy;
3899
+ const d2 = dx * dx + dy * dy;
3900
+ if (d2 < bestDist2) {
3901
+ bestDist2 = d2;
3902
+ bestIdx = i;
3903
+ }
3904
+ }
3905
+ if (bestIdx < 0) return null;
3906
+ return {
3907
+ index: bestIdx,
3908
+ worldPos: new THREE6.Vector3(attr.getX(bestIdx), attr.getY(bestIdx), attr.getZ(bestIdx))
3909
+ };
3910
+ }
3791
3911
  function onMouseDown(e) {
3792
3912
  state.lastMouseX = e.clientX;
3793
3913
  state.lastMouseY = e.clientY;
3794
3914
  if (currentConfig?.editable) {
3915
+ const rect = renderer.domElement.getBoundingClientRect();
3916
+ const mX = e.clientX - rect.left;
3917
+ const mY = e.clientY - rect.top;
3918
+ const starHit = screenSpacePickStar(mX, mY);
3919
+ if (starHit) {
3920
+ state.dragMode = "node";
3921
+ state.draggedStarIndex = starHit.index;
3922
+ state.draggedNodeId = starIndexToId[starHit.index] ?? null;
3923
+ state.draggedDist = starHit.worldPos.length();
3924
+ state.draggedGroup = null;
3925
+ state.tempArrangement = {};
3926
+ state.velocityX = 0;
3927
+ state.velocityY = 0;
3928
+ return;
3929
+ }
3795
3930
  const hit = pick(e);
3796
- if (hit) {
3931
+ if (hit && (hit.type === "label" || hit.type === "constellation")) {
3797
3932
  state.dragMode = "node";
3798
3933
  state.draggedNodeId = hit.node.id;
3799
- if (hit.type === "star" && hit.index !== void 0 && starPoints) {
3800
- const attr = starPoints.geometry.attributes.position;
3801
- const starWorldPos = new THREE6.Vector3(attr.getX(hit.index), attr.getY(hit.index), attr.getZ(hit.index));
3802
- state.draggedDist = starWorldPos.length();
3803
- } else {
3804
- state.draggedDist = hit.point.length();
3805
- }
3806
- document.body.style.cursor = "crosshair";
3934
+ state.draggedDist = hit.point.length();
3935
+ state.draggedStarIndex = -1;
3807
3936
  state.velocityX = 0;
3808
3937
  state.velocityY = 0;
3809
- if (hit.type === "star") {
3810
- state.draggedStarIndex = hit.index ?? -1;
3811
- state.draggedGroup = null;
3812
- state.tempArrangement = {};
3813
- } else if (hit.type === "label") {
3938
+ if (hit.type === "label") {
3814
3939
  const bookId = hit.node.id;
3815
3940
  const children = [];
3816
3941
  if (starPoints && starPoints.geometry.attributes.position) {
@@ -3825,12 +3950,21 @@ function createEngine({
3825
3950
  }
3826
3951
  }
3827
3952
  }
3828
- state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
3829
- state.draggedStarIndex = -1;
3830
- } else if (hit.type === "constellation") {
3831
- state.draggedGroup = { labelInitialPos: hit.point.clone(), children: [] };
3832
- state.draggedStarIndex = -1;
3953
+ const constellations = [];
3954
+ for (const cItem of constellationLayer.getItems()) {
3955
+ const anchored = cItem.config.anchors.some((anchorId) => {
3956
+ const n = nodeById.get(anchorId);
3957
+ return n?.parent === bookId;
3958
+ });
3959
+ if (anchored) {
3960
+ constellations.push({ id: cItem.config.id, initialCenter: cItem.center.clone() });
3961
+ }
3962
+ }
3963
+ state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children, constellations };
3964
+ } else {
3965
+ state.draggedGroup = { labelInitialPos: hit.point.clone(), children: [], constellations: [] };
3833
3966
  }
3967
+ return;
3834
3968
  }
3835
3969
  return;
3836
3970
  }
@@ -3857,7 +3991,6 @@ function createEngine({
3857
3991
  const attr = starPoints.geometry.attributes.position;
3858
3992
  attr.setXYZ(idx, newPos.x, newPos.y, newPos.z);
3859
3993
  attr.needsUpdate = true;
3860
- editHoverTargetPos = newPos.clone();
3861
3994
  const starId = starIndexToId[idx];
3862
3995
  if (starId) state.tempArrangement[starId] = { position: [newPos.x, newPos.y, newPos.z] };
3863
3996
  } else if (state.draggedGroup && state.draggedNodeId) {
@@ -3873,7 +4006,7 @@ function createEngine({
3873
4006
  const vE = newPos.clone().normalize();
3874
4007
  cItem.mesh.quaternion.setFromUnitVectors(vS, vE);
3875
4008
  cItem.center.copy(newPos);
3876
- state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
4009
+ state.tempArrangement[state.draggedNodeId] = { center: [newPos.x, newPos.y, newPos.z] };
3877
4010
  }
3878
4011
  }
3879
4012
  const vStart = group.labelInitialPos.clone().normalize();
@@ -3892,6 +4025,20 @@ function createEngine({
3892
4025
  }
3893
4026
  attr.needsUpdate = true;
3894
4027
  }
4028
+ if (group.constellations.length > 0) {
4029
+ for (const { id, initialCenter } of group.constellations) {
4030
+ const cItem = constellationLayer.getItems().find((c) => c.config.id === id);
4031
+ if (cItem) {
4032
+ const newCenter = initialCenter.clone().applyQuaternion(q);
4033
+ cItem.center.copy(newCenter);
4034
+ cItem.mesh.quaternion.setFromUnitVectors(
4035
+ initialCenter.clone().normalize(),
4036
+ newCenter.clone().normalize()
4037
+ );
4038
+ state.tempArrangement[id] = { center: [newCenter.x, newCenter.y, newCenter.z] };
4039
+ }
4040
+ }
4041
+ }
3895
4042
  }
3896
4043
  } else if (state.dragMode === "camera") {
3897
4044
  const deltaX = e.clientX - state.lastMouseX;
@@ -3899,13 +4046,15 @@ function createEngine({
3899
4046
  state.lastMouseX = e.clientX;
3900
4047
  state.lastMouseY = e.clientY;
3901
4048
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
3902
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
3903
- const latFactor = 1 - rotLock * rotLock;
3904
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
3905
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4049
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4050
+ const massFactor = getMovementMassFactor(state.fov);
4051
+ const moveX = compressInputDelta(deltaX) * massFactor;
4052
+ const moveY = compressInputDelta(deltaY) * massFactor;
4053
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4054
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
3906
4055
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
3907
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
3908
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4056
+ state.velocityX = moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4057
+ state.velocityY = moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
3909
4058
  state.lon = state.targetLon;
3910
4059
  state.lat = state.targetLat;
3911
4060
  } else {
@@ -3925,26 +4074,17 @@ function createEngine({
3925
4074
  hoverLabelMesh.position.copy(hit.point);
3926
4075
  hoverLabelMat.uniforms.uAlpha.value = 1;
3927
4076
  hoverLabelMesh.visible = true;
3928
- if (currentConfig?.editable && hit.type === "star" && hit.index !== void 0 && starPoints) {
3929
- const attr = starPoints.geometry.attributes.position;
3930
- editHoverTargetPos = new THREE6.Vector3(attr.getX(hit.index), attr.getY(hit.index), attr.getZ(hit.index));
3931
- } else if (currentConfig?.editable && hit.type === "star") {
3932
- editHoverTargetPos = hit.point.clone();
3933
- }
3934
4077
  } else {
3935
4078
  currentHoverNodeId = null;
3936
4079
  hoverLabelMat.uniforms.uAlpha.value = 0;
3937
4080
  hoverLabelMesh.visible = false;
3938
- if (currentConfig?.editable && state.dragMode !== "node") {
3939
- editHoverTargetPos = null;
3940
- }
3941
4081
  }
3942
4082
  if (hit?.node.id !== handlers._lastHoverId) {
3943
4083
  handlers._lastHoverId = hit?.node.id;
3944
4084
  handlers.onHover?.(hit?.node);
3945
4085
  constellationLayer.setHovered(hit?.node.id ?? null);
3946
4086
  }
3947
- document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
4087
+ document.body.style.cursor = hit ? currentConfig?.editable && hit.type === "star" ? "grab" : "pointer" : "default";
3948
4088
  }
3949
4089
  }
3950
4090
  function onMouseUp(e) {
@@ -3954,7 +4094,6 @@ function createEngine({
3954
4094
  if (state.dragMode === "node") {
3955
4095
  const fullArr = getFullArrangement();
3956
4096
  handlers.onArrangementChange?.(fullArr);
3957
- editDropFlash = 1;
3958
4097
  state.dragMode = "none";
3959
4098
  state.draggedNodeId = null;
3960
4099
  state.draggedStarIndex = -1;
@@ -3997,20 +4136,30 @@ function createEngine({
3997
4136
  const aspect = container.clientWidth / container.clientHeight;
3998
4137
  renderer.domElement.getBoundingClientRect();
3999
4138
  const vBefore = getMouseViewVector(state.fov, aspect);
4000
- const zoomSpeed = 1e-3 * state.fov;
4139
+ const zoomResistance = THREE6.MathUtils.lerp(
4140
+ 1,
4141
+ ENGINE_CONFIG.zoomResistanceWideFov,
4142
+ THREE6.MathUtils.smoothstep(state.fov, 24, 100)
4143
+ );
4144
+ const zoomSpeed = 1e-3 * state.fov * zoomResistance;
4001
4145
  state.fov += e.deltaY * zoomSpeed;
4002
4146
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
4003
4147
  handlers.onFovChange?.(state.fov);
4004
4148
  updateUniforms();
4005
4149
  const vAfter = getMouseViewVector(state.fov, aspect);
4006
4150
  const quaternion = new THREE6.Quaternion().setFromUnitVectors(vAfter, vBefore);
4007
- const dampStartFov = 40;
4008
- const dampEndFov = 120;
4151
+ const dampStartFov = 32;
4152
+ const dampEndFov = 110;
4009
4153
  let spinAmount = 1;
4010
4154
  if (state.fov > dampStartFov) {
4011
4155
  const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
4012
- spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
4156
+ spinAmount = 1 - Math.pow(t, 1.35) * 0.92;
4013
4157
  }
4158
+ const blendForSpin = getBlendForZenithControl();
4159
+ const blendSpinDamp = THREE6.MathUtils.smoothstep(blendForSpin, 0.58, 0.9);
4160
+ spinAmount *= 1 - 0.88 * blendSpinDamp;
4161
+ if (zenithProjectionLockActive) spinAmount = Math.min(spinAmount, 0.02);
4162
+ spinAmount = Math.max(0.02, Math.min(1, spinAmount));
4014
4163
  if (spinAmount < 0.999) {
4015
4164
  const identityQuat = new THREE6.Quaternion();
4016
4165
  quaternion.slerp(identityQuat, 1 - spinAmount);
@@ -4032,9 +4181,10 @@ function createEngine({
4032
4181
  state.lon = Math.atan2(newForward.x, -newForward.z);
4033
4182
  const newUp = new THREE6.Vector3(0, 1, 0).applyQuaternion(qNew);
4034
4183
  camera.up.copy(newUp);
4035
- if (!getSceneDebug()?.disableZenithBias && e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
4036
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
4037
- let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
4184
+ const zenithBiasStartFov = getZenithBiasStartFov();
4185
+ if (!zenithProjectionLockActive && !getSceneDebug()?.disableZenithBias && !isInTransitionFreezeBand(state.fov) && e.deltaY > 0 && state.fov > zenithBiasStartFov) {
4186
+ const range = ENGINE_CONFIG.maxFov - zenithBiasStartFov;
4187
+ let t = (state.fov - zenithBiasStartFov) / range;
4038
4188
  t = Math.max(0, Math.min(1, t));
4039
4189
  const bias = ENGINE_CONFIG.zenithStrength * t;
4040
4190
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -4126,13 +4276,15 @@ function createEngine({
4126
4276
  }
4127
4277
  }
4128
4278
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
4129
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
4130
- const latFactor = 1 - rotLock * rotLock;
4131
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
4132
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4279
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4280
+ const massFactor = getMovementMassFactor(state.fov);
4281
+ const moveX = compressInputDelta(deltaX) * massFactor;
4282
+ const moveY = compressInputDelta(deltaY) * massFactor;
4283
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4284
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4133
4285
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
4134
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
4135
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4286
+ state.velocityX = moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4287
+ state.velocityY = moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4136
4288
  state.lon = state.targetLon;
4137
4289
  state.lat = state.targetLat;
4138
4290
  } else if (touches.length === 2) {
@@ -4144,9 +4296,10 @@ function createEngine({
4144
4296
  state.fov = state.pinchStartFov / scale;
4145
4297
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
4146
4298
  handlers.onFovChange?.(state.fov);
4147
- if (!getSceneDebug()?.disableZenithBias && state.fov > prevFov && state.fov > ENGINE_CONFIG.zenithStartFov) {
4148
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
4149
- let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
4299
+ const zenithBiasStartFov = getZenithBiasStartFov();
4300
+ if (!zenithProjectionLockActive && !getSceneDebug()?.disableZenithBias && !isInTransitionFreezeBand(state.fov) && state.fov > prevFov && state.fov > zenithBiasStartFov) {
4301
+ const range = ENGINE_CONFIG.maxFov - zenithBiasStartFov;
4302
+ let t = (state.fov - zenithBiasStartFov) / range;
4150
4303
  t = Math.max(0, Math.min(1, t));
4151
4304
  const bias = ENGINE_CONFIG.zenithStrength * t;
4152
4305
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -4159,8 +4312,12 @@ function createEngine({
4159
4312
  state.lastMouseX = center.x;
4160
4313
  state.lastMouseY = center.y;
4161
4314
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
4162
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4163
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4315
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4316
+ const massFactor = getMovementMassFactor(state.fov);
4317
+ const moveX = compressInputDelta(deltaX) * massFactor;
4318
+ const moveY = compressInputDelta(deltaY) * massFactor;
4319
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4320
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5 * latFactor;
4164
4321
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
4165
4322
  state.lon = state.targetLon;
4166
4323
  state.lat = state.targetLat;
@@ -4323,7 +4480,9 @@ function createEngine({
4323
4480
  if (inZoneX || inZoneY) {
4324
4481
  if (edgeHoverStart === 0) edgeHoverStart = performance.now();
4325
4482
  if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
4326
- const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
4483
+ const edgeMassFactor = getMovementMassFactor(state.fov, ENGINE_CONFIG.edgePanMassWideFov);
4484
+ const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov) * edgeMassFactor;
4485
+ const verticalPanFactor = getVerticalPanFactor(state.fov, state.lat);
4327
4486
  if (mouseNDC.x < -1 + t) {
4328
4487
  const s = (-1 + t - mouseNDC.x) / t;
4329
4488
  panX = -s * s * speedBase;
@@ -4333,10 +4492,10 @@ function createEngine({
4333
4492
  }
4334
4493
  if (mouseNDC.y < -1 + t) {
4335
4494
  const s = (-1 + t - mouseNDC.y) / t;
4336
- panY = -s * s * speedBase;
4495
+ panY = -s * s * speedBase * verticalPanFactor;
4337
4496
  } else if (mouseNDC.y > 1 - t) {
4338
4497
  const s = (mouseNDC.y - (1 - t)) / t;
4339
- panY = s * s * speedBase;
4498
+ panY = s * s * speedBase * verticalPanFactor;
4340
4499
  }
4341
4500
  }
4342
4501
  } else {
@@ -4368,14 +4527,32 @@ function createEngine({
4368
4527
  state.targetLat = state.lat;
4369
4528
  } else if (!state.isDragging && !flyToActive) {
4370
4529
  state.lon += state.velocityX;
4530
+ state.velocityY *= getVerticalPanFactor(state.fov, state.lat);
4371
4531
  state.lat += state.velocityY;
4372
- const damping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
4532
+ const baseDamping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
4533
+ const speed = Math.hypot(state.velocityX, state.velocityY);
4534
+ const damping = speed < ENGINE_CONFIG.lowSpeedVelocityThreshold ? Math.min(baseDamping, ENGINE_CONFIG.lowSpeedInertiaDamping) : baseDamping;
4373
4535
  state.velocityX *= damping;
4374
4536
  state.velocityY *= damping;
4375
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
4376
- if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
4537
+ if (Math.abs(state.velocityX) < ENGINE_CONFIG.velocityStopThreshold) state.velocityX = 0;
4538
+ if (Math.abs(state.velocityY) < ENGINE_CONFIG.velocityStopThreshold) state.velocityY = 0;
4377
4539
  }
4378
4540
  state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
4541
+ if (!flyToActive) {
4542
+ const latDeg = THREE6.MathUtils.radToDeg(state.lat);
4543
+ if (latDeg < HORIZON_ZOOM_CONFIG.latStartDeg) {
4544
+ const t = THREE6.MathUtils.clamp(latDeg / HORIZON_ZOOM_CONFIG.latStartDeg, 0, 1);
4545
+ const maxFov = THREE6.MathUtils.lerp(
4546
+ HORIZON_ZOOM_CONFIG.safeFovAtHorizon,
4547
+ ENGINE_CONFIG.maxFov,
4548
+ t
4549
+ );
4550
+ if (state.fov > maxFov) {
4551
+ state.fov = THREE6.MathUtils.lerp(state.fov, maxFov, HORIZON_ZOOM_CONFIG.lerpRate);
4552
+ }
4553
+ }
4554
+ }
4555
+ applyZenithAutoCenter();
4379
4556
  const y = Math.sin(state.lat);
4380
4557
  const r = Math.cos(state.lat);
4381
4558
  const x = r * Math.sin(state.lon);
@@ -4388,11 +4565,13 @@ function createEngine({
4388
4565
  camera.updateMatrixWorld();
4389
4566
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
4390
4567
  if (groundMaterial?.uniforms?.uZenithFlatten) {
4391
- const flatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6.MathUtils.smoothstep(
4568
+ const targetFlatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6.MathUtils.smoothstep(
4392
4569
  state.lat,
4393
4570
  THREE6.MathUtils.degToRad(68),
4394
4571
  THREE6.MathUtils.degToRad(88)
4395
4572
  );
4573
+ const prevFlatten = Number(groundMaterial.uniforms.uZenithFlatten.value ?? 0);
4574
+ const flatten = isInTransitionFreezeBand(state.fov) ? THREE6.MathUtils.clamp(targetFlatten, prevFlatten - 0.01, prevFlatten + 0.01) : targetFlatten;
4396
4575
  groundMaterial.uniforms.uZenithFlatten.value = flatten;
4397
4576
  }
4398
4577
  updateUniforms();
@@ -4409,6 +4588,11 @@ function createEngine({
4409
4588
  const baseArtOpacity = THREE6.MathUtils.clamp(currentConfig?.constellationBaseOpacity ?? 1, 0, 300);
4410
4589
  constellationLayer.setGlobalOpacity?.(artFader.eased * baseArtOpacity);
4411
4590
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
4591
+ const revealZoom = currentConfig?.starZoomReveal ?? true ? THREE6.MathUtils.clamp(
4592
+ (ZOOM_REVEAL_CONFIG.wideFov - state.fov) / (ZOOM_REVEAL_CONFIG.wideFov - ZOOM_REVEAL_CONFIG.narrowFov),
4593
+ 0,
4594
+ 1
4595
+ ) : 1;
4412
4596
  if (backdropStarsMaterial?.uniforms) {
4413
4597
  const minGain = THREE6.MathUtils.clamp(currentConfig?.backdropWideFovGain ?? 0.42, 0, 1);
4414
4598
  const fovT = THREE6.MathUtils.smoothstep(state.fov, 24, 100);
@@ -4416,6 +4600,11 @@ function createEngine({
4416
4600
  backdropStarsMaterial.uniforms.uBackdropGain.value = gain;
4417
4601
  backdropStarsMaterial.uniforms.uBackdropEnergy.value = THREE6.MathUtils.clamp(currentConfig?.backdropEnergy ?? 2.2, 0.2, 5);
4418
4602
  backdropStarsMaterial.uniforms.uBackdropSizeExp.value = THREE6.MathUtils.clamp(currentConfig?.backdropSizeExponent ?? 0.9, 0.4, 1.4);
4603
+ backdropStarsMaterial.uniforms.uRevealZoom.value = revealZoom;
4604
+ }
4605
+ if (starPoints?.material) {
4606
+ const sm = starPoints.material;
4607
+ if (sm.uniforms.uRevealZoom) sm.uniforms.uRevealZoom.value = revealZoom;
4419
4608
  }
4420
4609
  if (skyBackgroundMesh) skyBackgroundMesh.visible = currentConfig?.background !== "transparent";
4421
4610
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
@@ -4425,32 +4614,6 @@ function createEngine({
4425
4614
  if (sunDiscMesh) sunDiscMesh.visible = showSun;
4426
4615
  if (sunHaloMesh) sunHaloMesh.visible = showSun;
4427
4616
  if (milkyWayMesh) milkyWayMesh.visible = currentConfig?.showMilkyWay ?? true;
4428
- if (editHoverMesh) {
4429
- const ringMat = editHoverMesh.material;
4430
- const isEditing = currentConfig?.editable ?? false;
4431
- const isDraggingStar = state.dragMode === "node" && state.draggedStarIndex !== -1;
4432
- const hasTarget = isEditing && editHoverTargetPos !== null;
4433
- if (hasTarget) {
4434
- editHoverMesh.position.copy(editHoverTargetPos);
4435
- const pulseBoost = editDropFlash * 1.8;
4436
- const targetAlpha = 0.8 + pulseBoost;
4437
- ringMat.uniforms.uRingAlpha.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingAlpha.value, targetAlpha, 0.15);
4438
- const tGold = isDraggingStar ? 1 : editDropFlash;
4439
- const targetColor = new THREE6.Color(
4440
- THREE6.MathUtils.lerp(0.55, 1, tGold),
4441
- THREE6.MathUtils.lerp(0.88, 0.82, tGold),
4442
- THREE6.MathUtils.lerp(1, 0.18, tGold)
4443
- );
4444
- ringMat.uniforms.uRingColor.value.lerp(targetColor, 0.18);
4445
- const baseSize = isDraggingStar ? 0.075 : 0.06;
4446
- const targetSize = baseSize * (1 + editDropFlash * 0.7);
4447
- ringMat.uniforms.uRingSize.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingSize.value, targetSize, 0.18);
4448
- editDropFlash = Math.max(0, editDropFlash - dt * 3);
4449
- } else {
4450
- ringMat.uniforms.uRingAlpha.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingAlpha.value, 0, 0.15);
4451
- ringMat.uniforms.uRingSize.value = THREE6.MathUtils.lerp(ringMat.uniforms.uRingSize.value, 0.06, 0.2);
4452
- }
4453
- }
4454
4617
  if (constellationLines) {
4455
4618
  constellationLines.visible = linesFader.eased > 0.01;
4456
4619
  if (constellationLines.visible && constellationLines.material) {
@@ -4558,12 +4721,6 @@ function createEngine({
4558
4721
  skyBackgroundMesh.material.dispose();
4559
4722
  skyBackgroundMesh = null;
4560
4723
  }
4561
- if (editHoverMesh) {
4562
- scene.remove(editHoverMesh);
4563
- editHoverMesh.geometry.dispose();
4564
- editHoverMesh.material.dispose();
4565
- editHoverMesh = null;
4566
- }
4567
4724
  renderer.dispose();
4568
4725
  renderer.domElement.remove();
4569
4726
  }
@@ -4606,7 +4763,7 @@ function createEngine({
4606
4763
  }
4607
4764
  return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
4608
4765
  }
4609
- var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
4766
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG, HORIZON_ZOOM_CONFIG, ZOOM_REVEAL_CONFIG;
4610
4767
  var init_createEngine = __esm({
4611
4768
  "src/engine/createEngine.ts"() {
4612
4769
  init_layout();
@@ -4621,8 +4778,28 @@ var init_createEngine = __esm({
4621
4778
  defaultFov: 35,
4622
4779
  dragSpeed: 125e-5,
4623
4780
  inertiaDamping: 0.92,
4781
+ lowSpeedInertiaDamping: 0.78,
4782
+ lowSpeedVelocityThreshold: 25e-4,
4783
+ velocityStopThreshold: 4e-5,
4784
+ zoomResistanceWideFov: 0.82,
4785
+ movementMassWideFov: 0.74,
4786
+ edgePanMassWideFov: 0.68,
4787
+ inputCompression: 0.018,
4624
4788
  blendStart: 35,
4625
4789
  blendEnd: 83,
4790
+ freezeBandStartFov: 76,
4791
+ freezeBandEndFov: 84,
4792
+ zenithBiasStartFov: 85,
4793
+ zenithLockBlendEnter: 0.9,
4794
+ zenithLockBlendExit: 0.8,
4795
+ zenithAutoCenterBlendStart: 0.62,
4796
+ zenithAutoCenterBlendEnd: 0.9,
4797
+ zenithAutoCenterMinLerp: 0.012,
4798
+ zenithAutoCenterMaxLerp: 0.16,
4799
+ verticalPanDampStartFov: 72,
4800
+ verticalPanDampEndFov: 96,
4801
+ verticalPanDampLatStartDeg: 45,
4802
+ verticalPanDampLatEndDeg: 82,
4626
4803
  zenithStartFov: 75,
4627
4804
  zenithStrength: 0.15,
4628
4805
  horizonLockStrength: 0.05,
@@ -4649,6 +4826,30 @@ var init_createEngine = __esm({
4649
4826
  pulseDuration: 2,
4650
4827
  delayPerChapter: 0.1
4651
4828
  };
4829
+ HORIZON_ZOOM_CONFIG = {
4830
+ latStartDeg: 20,
4831
+ // coupling is fully off above this elevation
4832
+ safeFovAtHorizon: 60,
4833
+ // max FOV at the horizon (below freeze-band threshold)
4834
+ lerpRate: 0.03
4835
+ // gentle — should feel like a natural breathing-in
4836
+ };
4837
+ ZOOM_REVEAL_CONFIG = {
4838
+ wideFov: 120,
4839
+ // above this FOV, revealZoom = 0 (nothing new revealed)
4840
+ narrowFov: 8,
4841
+ // below this FOV, revealZoom = 1 (everything visible)
4842
+ zoomCurveExp: 1.8,
4843
+ // non-linear curve exponent (try 1.5 – 2.5)
4844
+ chapterRevealMax: 0.5,
4845
+ // faintest chapter star threshold — visible by ~fov 35
4846
+ chapterFeather: 0.1,
4847
+ // smoothstep width for chapter star fade-in
4848
+ backdropRevealStart: 0.4,
4849
+ // backdrop starts appearing at this mappedZoom
4850
+ backdropRevealEnd: 0.65
4851
+ // backdrop fully visible at this mappedZoom
4852
+ };
4652
4853
  }
4653
4854
  });
4654
4855
  var StarMap = forwardRef(