@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.cjs CHANGED
@@ -1433,16 +1433,111 @@ function createEngine({
1433
1433
  function getSceneDebug() {
1434
1434
  return currentConfig?.debug?.sceneMechanics;
1435
1435
  }
1436
+ function getFreezeBand() {
1437
+ const dbg = getSceneDebug();
1438
+ const startRaw = dbg?.freezeBandStartFov ?? ENGINE_CONFIG.freezeBandStartFov;
1439
+ const endRaw = dbg?.freezeBandEndFov ?? ENGINE_CONFIG.freezeBandEndFov;
1440
+ const start2 = Math.min(startRaw, endRaw);
1441
+ const end = Math.max(startRaw, endRaw);
1442
+ return { start: start2, end };
1443
+ }
1444
+ function isInTransitionFreezeBand(fov) {
1445
+ const band = getFreezeBand();
1446
+ return fov >= band.start && fov <= band.end;
1447
+ }
1448
+ function getZenithBiasStartFov() {
1449
+ return getSceneDebug()?.zenithBiasStartFov ?? ENGINE_CONFIG.zenithBiasStartFov;
1450
+ }
1451
+ function getVerticalPanDampConfig() {
1452
+ const dbg = getSceneDebug();
1453
+ const fovStartRaw = dbg?.verticalPanDampStartFov ?? ENGINE_CONFIG.verticalPanDampStartFov;
1454
+ const fovEndRaw = dbg?.verticalPanDampEndFov ?? ENGINE_CONFIG.verticalPanDampEndFov;
1455
+ const latStartRaw = dbg?.verticalPanDampLatStartDeg ?? ENGINE_CONFIG.verticalPanDampLatStartDeg;
1456
+ const latEndRaw = dbg?.verticalPanDampLatEndDeg ?? ENGINE_CONFIG.verticalPanDampLatEndDeg;
1457
+ return {
1458
+ fovStart: Math.min(fovStartRaw, fovEndRaw),
1459
+ fovEnd: Math.max(fovStartRaw, fovEndRaw),
1460
+ latStartDeg: Math.min(latStartRaw, latEndRaw),
1461
+ latEndDeg: Math.max(latStartRaw, latEndRaw)
1462
+ };
1463
+ }
1464
+ function getVerticalPanFactor(fov, lat) {
1465
+ if (zenithProjectionLockActive) return 0;
1466
+ const cfg = getVerticalPanDampConfig();
1467
+ const fovT = THREE6__namespace.MathUtils.smoothstep(fov, cfg.fovStart, cfg.fovEnd);
1468
+ const zenithT = THREE6__namespace.MathUtils.smoothstep(
1469
+ Math.max(lat, 0),
1470
+ THREE6__namespace.MathUtils.degToRad(cfg.latStartDeg),
1471
+ THREE6__namespace.MathUtils.degToRad(cfg.latEndDeg)
1472
+ );
1473
+ const lock = Math.max(fovT * 0.65, fovT * zenithT);
1474
+ return THREE6__namespace.MathUtils.clamp(1 - lock, 0, 1);
1475
+ }
1476
+ function getMovementMassFactor(fov, wideFovFactor = ENGINE_CONFIG.movementMassWideFov) {
1477
+ const t = THREE6__namespace.MathUtils.smoothstep(fov, 24, 96);
1478
+ return THREE6__namespace.MathUtils.lerp(1, wideFovFactor, t);
1479
+ }
1480
+ function compressInputDelta(delta) {
1481
+ const absDelta = Math.abs(delta);
1482
+ if (absDelta < 1e-4) return 0;
1483
+ return Math.sign(delta) * (absDelta / (1 + absDelta * ENGINE_CONFIG.inputCompression));
1484
+ }
1436
1485
  const constellationLayer = new ConstellationArtworkLayer(scene);
1437
1486
  function mix(a, b, t) {
1438
1487
  return a * (1 - t) + b * t;
1439
1488
  }
1440
1489
  let currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
1490
+ let zenithProjectionLockActive = false;
1491
+ function getZenithLockBlendThresholds() {
1492
+ const enterRaw = ENGINE_CONFIG.zenithLockBlendEnter;
1493
+ const exitRaw = ENGINE_CONFIG.zenithLockBlendExit;
1494
+ return {
1495
+ enter: Math.max(0, Math.min(1, enterRaw)),
1496
+ exit: Math.max(0, Math.min(1, Math.min(exitRaw, enterRaw)))
1497
+ };
1498
+ }
1499
+ function getZenithLockLat() {
1500
+ return Math.PI / 2 - 1e-3;
1501
+ }
1502
+ function getBlendForZenithControl() {
1503
+ if (currentProjection instanceof BlendedProjection) return currentProjection.getBlend();
1504
+ return 0;
1505
+ }
1506
+ function applyZenithAutoCenter() {
1507
+ const zenithLat = getZenithLockLat();
1508
+ const blend = getBlendForZenithControl();
1509
+ let pullT = THREE6__namespace.MathUtils.smoothstep(
1510
+ blend,
1511
+ ENGINE_CONFIG.zenithAutoCenterBlendStart,
1512
+ ENGINE_CONFIG.zenithAutoCenterBlendEnd
1513
+ );
1514
+ if (zenithProjectionLockActive) pullT = 1;
1515
+ if (pullT <= 1e-4) return;
1516
+ const pullLerp = THREE6__namespace.MathUtils.lerp(
1517
+ ENGINE_CONFIG.zenithAutoCenterMinLerp,
1518
+ ENGINE_CONFIG.zenithAutoCenterMaxLerp,
1519
+ pullT
1520
+ );
1521
+ state.lat = THREE6__namespace.MathUtils.lerp(state.lat, zenithLat, pullLerp);
1522
+ state.targetLat = THREE6__namespace.MathUtils.lerp(state.targetLat, zenithLat, Math.min(1, pullLerp * 1.15));
1523
+ state.velocityY *= 1 - 0.85 * pullT;
1524
+ if (zenithProjectionLockActive && Math.abs(state.lat - zenithLat) < 25e-5) {
1525
+ state.lat = zenithLat;
1526
+ state.targetLat = zenithLat;
1527
+ state.velocityY = 0;
1528
+ }
1529
+ }
1441
1530
  function syncProjectionState() {
1442
1531
  if (currentProjection instanceof BlendedProjection) {
1443
1532
  currentProjection.setFov(state.fov);
1444
1533
  currentProjection.setBlendOverride(getSceneDebug()?.projectionBlendOverride ?? null);
1445
1534
  globalUniforms.uBlend.value = currentProjection.getBlend();
1535
+ const blend = currentProjection.getBlend();
1536
+ const th = getZenithLockBlendThresholds();
1537
+ if (!zenithProjectionLockActive && blend >= th.enter) zenithProjectionLockActive = true;
1538
+ else if (zenithProjectionLockActive && blend <= th.exit) zenithProjectionLockActive = false;
1539
+ } else {
1540
+ zenithProjectionLockActive = false;
1446
1541
  }
1447
1542
  globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
1448
1543
  }
@@ -1699,8 +1794,13 @@ function createEngine({
1699
1794
  }
1700
1795
  const flatten = groundMaterial?.uniforms?.uZenithFlatten?.value;
1701
1796
  const blend = currentProjection instanceof BlendedProjection ? currentProjection.getBlend() : -1;
1797
+ const freeze = isInTransitionFreezeBand(state.fov) ? 1 : 0;
1798
+ const zenithBiasStart = getZenithBiasStartFov();
1799
+ const vPanCfg = getVerticalPanDampConfig();
1800
+ const vPan = getVerticalPanFactor(state.fov, state.lat);
1801
+ const moveMass = getMovementMassFactor(state.fov);
1702
1802
  console.debug(
1703
- `[HorizonDiag] fov=${state.fov.toFixed(1)} latDeg=${THREE6__namespace.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)}`
1803
+ `[HorizonDiag] fov=${state.fov.toFixed(1)} latDeg=${THREE6__namespace.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)}`
1704
1804
  );
1705
1805
  }
1706
1806
  function createGround() {
@@ -1849,9 +1949,6 @@ function createEngine({
1849
1949
  let sunDiscMesh = null;
1850
1950
  let sunHaloMesh = null;
1851
1951
  let milkyWayMesh = null;
1852
- let editHoverMesh = null;
1853
- let editHoverTargetPos = null;
1854
- let editDropFlash = 0;
1855
1952
  function createSkyBackground() {
1856
1953
  const geo = new THREE6__namespace.SphereGeometry(2400, 32, 32);
1857
1954
  const mat = createSmartMaterial({
@@ -2403,7 +2500,8 @@ function createEngine({
2403
2500
  uTime: globalUniforms.uTime,
2404
2501
  uBackdropGain: { value: 1 },
2405
2502
  uBackdropEnergy: { value: 2.2 },
2406
- uBackdropSizeExp: { value: 0.9 }
2503
+ uBackdropSizeExp: { value: 0.9 },
2504
+ uRevealZoom: { value: 0 }
2407
2505
  },
2408
2506
  vertexShaderBody: `
2409
2507
  attribute float size;
@@ -2417,6 +2515,7 @@ function createEngine({
2417
2515
  uniform float uBackdropGain;
2418
2516
  uniform float uBackdropEnergy;
2419
2517
  uniform float uBackdropSizeExp;
2518
+ uniform float uRevealZoom;
2420
2519
 
2421
2520
  void main() {
2422
2521
  vec3 nPos = normalize(position);
@@ -2432,7 +2531,12 @@ function createEngine({
2432
2531
  float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
2433
2532
  float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
2434
2533
 
2435
- vColor = color * uBackdropEnergy * extinction * horizonFade * scintillation * uBackdropGain;
2534
+ // Backdrop appears latest \u2014 fully hidden at wide FOV, emerges when zoomed in.
2535
+ // Thresholds come from ZOOM_REVEAL_CONFIG (baked at startup).
2536
+ float mappedZoom = pow(uRevealZoom, ${ZOOM_REVEAL_CONFIG.zoomCurveExp});
2537
+ float backdropReveal = smoothstep(${ZOOM_REVEAL_CONFIG.backdropRevealStart}, ${ZOOM_REVEAL_CONFIG.backdropRevealEnd}, mappedZoom);
2538
+
2539
+ vColor = color * uBackdropEnergy * extinction * horizonFade * scintillation * uBackdropGain * backdropReveal;
2436
2540
 
2437
2541
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
2438
2542
  gl_Position = smartProject(mvPosition);
@@ -2472,55 +2576,6 @@ function createEngine({
2472
2576
  points.frustumCulled = false;
2473
2577
  backdropGroup.add(points);
2474
2578
  }
2475
- function createEditHoverRing() {
2476
- const geo = new THREE6__namespace.PlaneGeometry(1, 1);
2477
- const mat = createSmartMaterial({
2478
- uniforms: {
2479
- uRingSize: { value: 0.06 },
2480
- uRingAlpha: { value: 0 },
2481
- uRingColor: { value: new THREE6__namespace.Color(0.55, 0.88, 1) }
2482
- },
2483
- vertexShaderBody: `
2484
- uniform float uRingSize;
2485
- varying vec2 vUv;
2486
- void main() {
2487
- vUv = uv;
2488
- vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2489
- vec4 proj = smartProject(mvPos);
2490
- if (proj.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2491
- vec2 offset = position.xy * uRingSize * uScale;
2492
- proj.xy += offset / vec2(uAspect, 1.0);
2493
- vScreenPos = proj.xy / proj.w;
2494
- gl_Position = proj;
2495
- }
2496
- `,
2497
- fragmentShader: `
2498
- varying vec2 vUv;
2499
- uniform float uRingAlpha;
2500
- uniform vec3 uRingColor;
2501
- void main() {
2502
- float alphaMask = getMaskAlpha();
2503
- if (alphaMask < 0.01) discard;
2504
- vec2 p = vUv * 2.0 - 1.0;
2505
- float d = length(p);
2506
- float ring = smoothstep(0.52, 0.62, d) * (1.0 - smoothstep(0.80, 0.92, d));
2507
- float glow = (1.0 - smoothstep(0.55, 0.98, d)) * 0.18;
2508
- float a = (ring + glow) * uRingAlpha * alphaMask;
2509
- if (a < 0.005) discard;
2510
- gl_FragColor = vec4(uRingColor * (ring * 1.2 + glow), a);
2511
- }
2512
- `,
2513
- transparent: true,
2514
- depthWrite: false,
2515
- depthTest: false,
2516
- side: THREE6__namespace.DoubleSide,
2517
- blending: THREE6__namespace.AdditiveBlending
2518
- });
2519
- editHoverMesh = new THREE6__namespace.Mesh(geo, mat);
2520
- editHoverMesh.renderOrder = 500;
2521
- editHoverMesh.frustumCulled = false;
2522
- scene.add(editHoverMesh);
2523
- }
2524
2579
  createSkyBackground();
2525
2580
  createGround();
2526
2581
  createAtmosphere();
@@ -2528,7 +2583,6 @@ function createEngine({
2528
2583
  createSun();
2529
2584
  createMilkyWay();
2530
2585
  createBackdropStars();
2531
- createEditHoverRing();
2532
2586
  const raycaster = new THREE6__namespace.Raycaster();
2533
2587
  raycaster.params.Points.threshold = 5;
2534
2588
  new THREE6__namespace.Vector2();
@@ -2635,7 +2689,7 @@ function createEngine({
2635
2689
  function getPosition(n) {
2636
2690
  if (currentConfig?.arrangement) {
2637
2691
  const arr = currentConfig.arrangement[n.id];
2638
- if (arr) {
2692
+ if (arr?.position) {
2639
2693
  const [px, py, pz] = arr.position;
2640
2694
  if (pz === 0) {
2641
2695
  const radius = currentConfig.layout?.radius ?? 2e3;
@@ -2784,6 +2838,7 @@ function createEngine({
2784
2838
  const starChapterIndices = [];
2785
2839
  const starTestamentIndices = [];
2786
2840
  const starDivisionIndices = [];
2841
+ const starRevealThresholds = [];
2787
2842
  const chapterLineCutById = /* @__PURE__ */ new Map();
2788
2843
  const chapterStarSizeById = /* @__PURE__ */ new Map();
2789
2844
  const chapterWeightNormById = /* @__PURE__ */ new Map();
@@ -2820,12 +2875,23 @@ function createEngine({
2820
2875
  } else if (minWeight === maxWeight) {
2821
2876
  maxWeight = minWeight + 1;
2822
2877
  }
2878
+ {
2879
+ const pctCap = THREE6__namespace.MathUtils.clamp(cfg.starSizeWeightPercentile ?? 0.95, 0.5, 1);
2880
+ const allWeights = [];
2881
+ for (const n of laidOut.nodes) {
2882
+ if (n.level === 3 && typeof n.weight === "number") allWeights.push(n.weight);
2883
+ }
2884
+ allWeights.sort((a, b) => a - b);
2885
+ const capIdx = Math.min(Math.floor(pctCap * allWeights.length), allWeights.length - 1);
2886
+ const cappedMax = allWeights[capIdx];
2887
+ if (cappedMax !== void 0 && cappedMax > minWeight) maxWeight = cappedMax;
2888
+ }
2823
2889
  for (const n of laidOut.nodes) {
2824
2890
  if (n.level === 3) {
2825
2891
  let baseSize = 3.5;
2826
2892
  let weightNorm = 0;
2827
2893
  if (typeof n.weight === "number") {
2828
- weightNorm = (n.weight - minWeight) / (maxWeight - minWeight);
2894
+ weightNorm = THREE6__namespace.MathUtils.clamp((n.weight - minWeight) / (maxWeight - minWeight), 0, 1);
2829
2895
  const sizeExp = cfg.starSizeExponent ?? 4;
2830
2896
  const sizeScale = cfg.starSizeScale ?? 6;
2831
2897
  baseSize = Math.pow(weightNorm, sizeExp) * 22 * sizeScale;
@@ -2850,6 +2916,14 @@ function createEngine({
2850
2916
  starIndexToId.push(n.id);
2851
2917
  const baseSize = chapterStarSizeById.get(n.id) ?? 3.5;
2852
2918
  starSizes.push(baseSize);
2919
+ {
2920
+ const wn = chapterWeightNormById.get(n.id) ?? 0;
2921
+ starRevealThresholds.push(THREE6__namespace.MathUtils.lerp(
2922
+ -ZOOM_REVEAL_CONFIG.chapterFeather,
2923
+ ZOOM_REVEAL_CONFIG.chapterRevealMax,
2924
+ 1 - wn
2925
+ ));
2926
+ }
2853
2927
  chapterLineCutById.set(
2854
2928
  n.id,
2855
2929
  THREE6__namespace.MathUtils.clamp(2.5 + baseSize * 0.45, 3, 40)
@@ -3000,6 +3074,7 @@ function createEngine({
3000
3074
  starGeo.setAttribute("chapterIndex", new THREE6__namespace.Float32BufferAttribute(starChapterIndices, 1));
3001
3075
  starGeo.setAttribute("testamentIndex", new THREE6__namespace.Float32BufferAttribute(starTestamentIndices, 1));
3002
3076
  starGeo.setAttribute("divisionIndex", new THREE6__namespace.Float32BufferAttribute(starDivisionIndices, 1));
3077
+ starGeo.setAttribute("revealThreshold", new THREE6__namespace.Float32BufferAttribute(starRevealThresholds, 1));
3003
3078
  const starMat = createSmartMaterial({
3004
3079
  uniforms: {
3005
3080
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -3017,19 +3092,22 @@ function createEngine({
3017
3092
  uFilterDivisionIndex: { value: -1 },
3018
3093
  uFilterBookIndex: { value: -1 },
3019
3094
  uFilterStrength: { value: 0 },
3020
- uFilterDimFactor: { value: 0.08 }
3095
+ uFilterDimFactor: { value: 0.08 },
3096
+ uRevealZoom: { value: 0 }
3021
3097
  },
3022
3098
  vertexShaderBody: `
3023
- attribute float size;
3024
- attribute vec3 color;
3099
+ attribute float size;
3100
+ attribute vec3 color;
3025
3101
  attribute float phase;
3026
3102
  attribute float bookIndex;
3027
3103
  attribute float chapterIndex;
3028
3104
  attribute float testamentIndex;
3029
3105
  attribute float divisionIndex;
3106
+ attribute float revealThreshold;
3030
3107
 
3031
3108
  varying vec3 vColor;
3032
3109
  varying float vSize;
3110
+ varying float vReveal;
3033
3111
  uniform float pixelRatio;
3034
3112
 
3035
3113
  uniform float uTime;
@@ -3046,6 +3124,7 @@ function createEngine({
3046
3124
  uniform float uFilterBookIndex;
3047
3125
  uniform float uFilterStrength;
3048
3126
  uniform float uFilterDimFactor;
3127
+ uniform float uRevealZoom;
3049
3128
 
3050
3129
  void main() {
3051
3130
  vec3 nPos = normalize(position);
@@ -3108,11 +3187,17 @@ function createEngine({
3108
3187
  float perceptualSize = pow(size, 0.7);
3109
3188
  gl_PointSize = clamp((perceptualSize * sizeBoost * 20.0) * uScale * pixelRatio * (2000.0 / length(mvPosition.xyz)) * horizonFade, 1.0, 600.0);
3110
3189
  vSize = gl_PointSize;
3190
+
3191
+ // Zoom-based reveal: faint stars hide at wide FOV, fade in as user zooms.
3192
+ // Exponent and feather baked from ZOOM_REVEAL_CONFIG at startup.
3193
+ float mappedZoom = pow(uRevealZoom, ${ZOOM_REVEAL_CONFIG.zoomCurveExp});
3194
+ vReveal = smoothstep(revealThreshold, revealThreshold + ${ZOOM_REVEAL_CONFIG.chapterFeather}, mappedZoom);
3111
3195
  }
3112
3196
  `,
3113
3197
  fragmentShader: `
3114
3198
  varying vec3 vColor;
3115
3199
  varying float vSize;
3200
+ varying float vReveal;
3116
3201
  void main() {
3117
3202
  vec2 coord = gl_PointCoord - vec2(0.5);
3118
3203
  float d = length(coord) * 2.0;
@@ -3141,7 +3226,8 @@ function createEngine({
3141
3226
  float spikeV = exp(-coord.x * coord.x * 180.0) * exp(-abs(coord.y) * 6.0);
3142
3227
  float spikes = (spikeH + spikeV) * 0.18 * spikeFactor;
3143
3228
 
3144
- gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, 1.0);
3229
+ // vReveal drives the additive contribution (AdditiveBlending uses SRC_ALPHA).
3230
+ gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, vReveal);
3145
3231
  }
3146
3232
  `,
3147
3233
  transparent: true,
@@ -3646,20 +3732,23 @@ function createEngine({
3646
3732
  constellationLayer.load(cfg.constellations, (id) => {
3647
3733
  if (cfg.arrangement && cfg.arrangement[id]) {
3648
3734
  const arr = cfg.arrangement[id];
3649
- if (arr.position[2] === 0) {
3650
- const x = arr.position[0];
3651
- const y = arr.position[1];
3652
- const radius = cfg.layout?.radius ?? 2e3;
3653
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3654
- const phi = Math.atan2(y, x);
3655
- const theta = r_norm * (Math.PI / 2);
3656
- return new THREE6__namespace.Vector3(
3657
- Math.sin(theta) * Math.cos(phi),
3658
- Math.cos(theta),
3659
- Math.sin(theta) * Math.sin(phi)
3660
- ).multiplyScalar(radius);
3735
+ const coords = arr.center ?? arr.position;
3736
+ if (coords) {
3737
+ if (coords[2] === 0) {
3738
+ const x = coords[0];
3739
+ const y = coords[1];
3740
+ const radius = cfg.layout?.radius ?? 2e3;
3741
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3742
+ const phi = Math.atan2(y, x);
3743
+ const theta = r_norm * (Math.PI / 2);
3744
+ return new THREE6__namespace.Vector3(
3745
+ Math.sin(theta) * Math.cos(phi),
3746
+ Math.cos(theta),
3747
+ Math.sin(theta) * Math.sin(phi)
3748
+ ).multiplyScalar(radius);
3749
+ }
3750
+ return new THREE6__namespace.Vector3(coords[0], coords[1], coords[2]);
3661
3751
  }
3662
- return new THREE6__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
3663
3752
  }
3664
3753
  return getLayoutPosition(id);
3665
3754
  }, getLayoutPosition);
@@ -3687,7 +3776,7 @@ function createEngine({
3687
3776
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
3688
3777
  }
3689
3778
  for (const item of constellationLayer.getItems()) {
3690
- arr[item.config.id] = { position: [item.center.x, item.center.y, item.center.z] };
3779
+ arr[item.config.id] = { center: [item.center.x, item.center.y, item.center.z] };
3691
3780
  }
3692
3781
  Object.assign(arr, state.tempArrangement);
3693
3782
  return arr;
@@ -3810,29 +3899,65 @@ function createEngine({
3810
3899
  isMouseInWindow = false;
3811
3900
  edgeHoverStart = 0;
3812
3901
  }
3902
+ function screenSpacePickStar(mx, my, maxPx = 50) {
3903
+ if (!starPoints) return null;
3904
+ const attr = starPoints.geometry.attributes.position;
3905
+ const rect = renderer.domElement.getBoundingClientRect();
3906
+ const w = rect.width;
3907
+ const h = rect.height;
3908
+ const uScale = globalUniforms.uScale.value;
3909
+ const uAspect = globalUniforms.uAspect.value;
3910
+ let bestIdx = -1;
3911
+ let bestDist2 = maxPx * maxPx;
3912
+ const worldPos = new THREE6__namespace.Vector3();
3913
+ for (let i = 0; i < attr.count; i++) {
3914
+ worldPos.set(attr.getX(i), attr.getY(i), attr.getZ(i));
3915
+ const proj = smartProjectJS(worldPos);
3916
+ if (currentProjection.isClipped(proj.z)) continue;
3917
+ const sx = (proj.x * uScale / uAspect * 0.5 + 0.5) * w;
3918
+ const sy = (-(proj.y * uScale) * 0.5 + 0.5) * h;
3919
+ const dx = mx - sx;
3920
+ const dy = my - sy;
3921
+ const d2 = dx * dx + dy * dy;
3922
+ if (d2 < bestDist2) {
3923
+ bestDist2 = d2;
3924
+ bestIdx = i;
3925
+ }
3926
+ }
3927
+ if (bestIdx < 0) return null;
3928
+ return {
3929
+ index: bestIdx,
3930
+ worldPos: new THREE6__namespace.Vector3(attr.getX(bestIdx), attr.getY(bestIdx), attr.getZ(bestIdx))
3931
+ };
3932
+ }
3813
3933
  function onMouseDown(e) {
3814
3934
  state.lastMouseX = e.clientX;
3815
3935
  state.lastMouseY = e.clientY;
3816
3936
  if (currentConfig?.editable) {
3937
+ const rect = renderer.domElement.getBoundingClientRect();
3938
+ const mX = e.clientX - rect.left;
3939
+ const mY = e.clientY - rect.top;
3940
+ const starHit = screenSpacePickStar(mX, mY);
3941
+ if (starHit) {
3942
+ state.dragMode = "node";
3943
+ state.draggedStarIndex = starHit.index;
3944
+ state.draggedNodeId = starIndexToId[starHit.index] ?? null;
3945
+ state.draggedDist = starHit.worldPos.length();
3946
+ state.draggedGroup = null;
3947
+ state.tempArrangement = {};
3948
+ state.velocityX = 0;
3949
+ state.velocityY = 0;
3950
+ return;
3951
+ }
3817
3952
  const hit = pick(e);
3818
- if (hit) {
3953
+ if (hit && (hit.type === "label" || hit.type === "constellation")) {
3819
3954
  state.dragMode = "node";
3820
3955
  state.draggedNodeId = hit.node.id;
3821
- if (hit.type === "star" && hit.index !== void 0 && starPoints) {
3822
- const attr = starPoints.geometry.attributes.position;
3823
- const starWorldPos = new THREE6__namespace.Vector3(attr.getX(hit.index), attr.getY(hit.index), attr.getZ(hit.index));
3824
- state.draggedDist = starWorldPos.length();
3825
- } else {
3826
- state.draggedDist = hit.point.length();
3827
- }
3828
- document.body.style.cursor = "crosshair";
3956
+ state.draggedDist = hit.point.length();
3957
+ state.draggedStarIndex = -1;
3829
3958
  state.velocityX = 0;
3830
3959
  state.velocityY = 0;
3831
- if (hit.type === "star") {
3832
- state.draggedStarIndex = hit.index ?? -1;
3833
- state.draggedGroup = null;
3834
- state.tempArrangement = {};
3835
- } else if (hit.type === "label") {
3960
+ if (hit.type === "label") {
3836
3961
  const bookId = hit.node.id;
3837
3962
  const children = [];
3838
3963
  if (starPoints && starPoints.geometry.attributes.position) {
@@ -3847,12 +3972,21 @@ function createEngine({
3847
3972
  }
3848
3973
  }
3849
3974
  }
3850
- state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
3851
- state.draggedStarIndex = -1;
3852
- } else if (hit.type === "constellation") {
3853
- state.draggedGroup = { labelInitialPos: hit.point.clone(), children: [] };
3854
- state.draggedStarIndex = -1;
3975
+ const constellations = [];
3976
+ for (const cItem of constellationLayer.getItems()) {
3977
+ const anchored = cItem.config.anchors.some((anchorId) => {
3978
+ const n = nodeById.get(anchorId);
3979
+ return n?.parent === bookId;
3980
+ });
3981
+ if (anchored) {
3982
+ constellations.push({ id: cItem.config.id, initialCenter: cItem.center.clone() });
3983
+ }
3984
+ }
3985
+ state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children, constellations };
3986
+ } else {
3987
+ state.draggedGroup = { labelInitialPos: hit.point.clone(), children: [], constellations: [] };
3855
3988
  }
3989
+ return;
3856
3990
  }
3857
3991
  return;
3858
3992
  }
@@ -3879,7 +4013,6 @@ function createEngine({
3879
4013
  const attr = starPoints.geometry.attributes.position;
3880
4014
  attr.setXYZ(idx, newPos.x, newPos.y, newPos.z);
3881
4015
  attr.needsUpdate = true;
3882
- editHoverTargetPos = newPos.clone();
3883
4016
  const starId = starIndexToId[idx];
3884
4017
  if (starId) state.tempArrangement[starId] = { position: [newPos.x, newPos.y, newPos.z] };
3885
4018
  } else if (state.draggedGroup && state.draggedNodeId) {
@@ -3895,7 +4028,7 @@ function createEngine({
3895
4028
  const vE = newPos.clone().normalize();
3896
4029
  cItem.mesh.quaternion.setFromUnitVectors(vS, vE);
3897
4030
  cItem.center.copy(newPos);
3898
- state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
4031
+ state.tempArrangement[state.draggedNodeId] = { center: [newPos.x, newPos.y, newPos.z] };
3899
4032
  }
3900
4033
  }
3901
4034
  const vStart = group.labelInitialPos.clone().normalize();
@@ -3914,6 +4047,20 @@ function createEngine({
3914
4047
  }
3915
4048
  attr.needsUpdate = true;
3916
4049
  }
4050
+ if (group.constellations.length > 0) {
4051
+ for (const { id, initialCenter } of group.constellations) {
4052
+ const cItem = constellationLayer.getItems().find((c) => c.config.id === id);
4053
+ if (cItem) {
4054
+ const newCenter = initialCenter.clone().applyQuaternion(q);
4055
+ cItem.center.copy(newCenter);
4056
+ cItem.mesh.quaternion.setFromUnitVectors(
4057
+ initialCenter.clone().normalize(),
4058
+ newCenter.clone().normalize()
4059
+ );
4060
+ state.tempArrangement[id] = { center: [newCenter.x, newCenter.y, newCenter.z] };
4061
+ }
4062
+ }
4063
+ }
3917
4064
  }
3918
4065
  } else if (state.dragMode === "camera") {
3919
4066
  const deltaX = e.clientX - state.lastMouseX;
@@ -3921,13 +4068,15 @@ function createEngine({
3921
4068
  state.lastMouseX = e.clientX;
3922
4069
  state.lastMouseY = e.clientY;
3923
4070
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
3924
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
3925
- const latFactor = 1 - rotLock * rotLock;
3926
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
3927
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4071
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4072
+ const massFactor = getMovementMassFactor(state.fov);
4073
+ const moveX = compressInputDelta(deltaX) * massFactor;
4074
+ const moveY = compressInputDelta(deltaY) * massFactor;
4075
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4076
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
3928
4077
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
3929
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
3930
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4078
+ state.velocityX = moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4079
+ state.velocityY = moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
3931
4080
  state.lon = state.targetLon;
3932
4081
  state.lat = state.targetLat;
3933
4082
  } else {
@@ -3947,26 +4096,17 @@ function createEngine({
3947
4096
  hoverLabelMesh.position.copy(hit.point);
3948
4097
  hoverLabelMat.uniforms.uAlpha.value = 1;
3949
4098
  hoverLabelMesh.visible = true;
3950
- if (currentConfig?.editable && hit.type === "star" && hit.index !== void 0 && starPoints) {
3951
- const attr = starPoints.geometry.attributes.position;
3952
- editHoverTargetPos = new THREE6__namespace.Vector3(attr.getX(hit.index), attr.getY(hit.index), attr.getZ(hit.index));
3953
- } else if (currentConfig?.editable && hit.type === "star") {
3954
- editHoverTargetPos = hit.point.clone();
3955
- }
3956
4099
  } else {
3957
4100
  currentHoverNodeId = null;
3958
4101
  hoverLabelMat.uniforms.uAlpha.value = 0;
3959
4102
  hoverLabelMesh.visible = false;
3960
- if (currentConfig?.editable && state.dragMode !== "node") {
3961
- editHoverTargetPos = null;
3962
- }
3963
4103
  }
3964
4104
  if (hit?.node.id !== handlers._lastHoverId) {
3965
4105
  handlers._lastHoverId = hit?.node.id;
3966
4106
  handlers.onHover?.(hit?.node);
3967
4107
  constellationLayer.setHovered(hit?.node.id ?? null);
3968
4108
  }
3969
- document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
4109
+ document.body.style.cursor = hit ? currentConfig?.editable && hit.type === "star" ? "grab" : "pointer" : "default";
3970
4110
  }
3971
4111
  }
3972
4112
  function onMouseUp(e) {
@@ -3976,7 +4116,6 @@ function createEngine({
3976
4116
  if (state.dragMode === "node") {
3977
4117
  const fullArr = getFullArrangement();
3978
4118
  handlers.onArrangementChange?.(fullArr);
3979
- editDropFlash = 1;
3980
4119
  state.dragMode = "none";
3981
4120
  state.draggedNodeId = null;
3982
4121
  state.draggedStarIndex = -1;
@@ -4019,20 +4158,30 @@ function createEngine({
4019
4158
  const aspect = container.clientWidth / container.clientHeight;
4020
4159
  renderer.domElement.getBoundingClientRect();
4021
4160
  const vBefore = getMouseViewVector(state.fov, aspect);
4022
- const zoomSpeed = 1e-3 * state.fov;
4161
+ const zoomResistance = THREE6__namespace.MathUtils.lerp(
4162
+ 1,
4163
+ ENGINE_CONFIG.zoomResistanceWideFov,
4164
+ THREE6__namespace.MathUtils.smoothstep(state.fov, 24, 100)
4165
+ );
4166
+ const zoomSpeed = 1e-3 * state.fov * zoomResistance;
4023
4167
  state.fov += e.deltaY * zoomSpeed;
4024
4168
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
4025
4169
  handlers.onFovChange?.(state.fov);
4026
4170
  updateUniforms();
4027
4171
  const vAfter = getMouseViewVector(state.fov, aspect);
4028
4172
  const quaternion = new THREE6__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
4029
- const dampStartFov = 40;
4030
- const dampEndFov = 120;
4173
+ const dampStartFov = 32;
4174
+ const dampEndFov = 110;
4031
4175
  let spinAmount = 1;
4032
4176
  if (state.fov > dampStartFov) {
4033
4177
  const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
4034
- spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
4178
+ spinAmount = 1 - Math.pow(t, 1.35) * 0.92;
4035
4179
  }
4180
+ const blendForSpin = getBlendForZenithControl();
4181
+ const blendSpinDamp = THREE6__namespace.MathUtils.smoothstep(blendForSpin, 0.58, 0.9);
4182
+ spinAmount *= 1 - 0.88 * blendSpinDamp;
4183
+ if (zenithProjectionLockActive) spinAmount = Math.min(spinAmount, 0.02);
4184
+ spinAmount = Math.max(0.02, Math.min(1, spinAmount));
4036
4185
  if (spinAmount < 0.999) {
4037
4186
  const identityQuat = new THREE6__namespace.Quaternion();
4038
4187
  quaternion.slerp(identityQuat, 1 - spinAmount);
@@ -4054,9 +4203,10 @@ function createEngine({
4054
4203
  state.lon = Math.atan2(newForward.x, -newForward.z);
4055
4204
  const newUp = new THREE6__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
4056
4205
  camera.up.copy(newUp);
4057
- if (!getSceneDebug()?.disableZenithBias && e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
4058
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
4059
- let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
4206
+ const zenithBiasStartFov = getZenithBiasStartFov();
4207
+ if (!zenithProjectionLockActive && !getSceneDebug()?.disableZenithBias && !isInTransitionFreezeBand(state.fov) && e.deltaY > 0 && state.fov > zenithBiasStartFov) {
4208
+ const range = ENGINE_CONFIG.maxFov - zenithBiasStartFov;
4209
+ let t = (state.fov - zenithBiasStartFov) / range;
4060
4210
  t = Math.max(0, Math.min(1, t));
4061
4211
  const bias = ENGINE_CONFIG.zenithStrength * t;
4062
4212
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -4148,13 +4298,15 @@ function createEngine({
4148
4298
  }
4149
4299
  }
4150
4300
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
4151
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
4152
- const latFactor = 1 - rotLock * rotLock;
4153
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
4154
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4301
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4302
+ const massFactor = getMovementMassFactor(state.fov);
4303
+ const moveX = compressInputDelta(deltaX) * massFactor;
4304
+ const moveY = compressInputDelta(deltaY) * massFactor;
4305
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4306
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4155
4307
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
4156
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
4157
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4308
+ state.velocityX = moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4309
+ state.velocityY = moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4158
4310
  state.lon = state.targetLon;
4159
4311
  state.lat = state.targetLat;
4160
4312
  } else if (touches.length === 2) {
@@ -4166,9 +4318,10 @@ function createEngine({
4166
4318
  state.fov = state.pinchStartFov / scale;
4167
4319
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
4168
4320
  handlers.onFovChange?.(state.fov);
4169
- if (!getSceneDebug()?.disableZenithBias && state.fov > prevFov && state.fov > ENGINE_CONFIG.zenithStartFov) {
4170
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
4171
- let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
4321
+ const zenithBiasStartFov = getZenithBiasStartFov();
4322
+ if (!zenithProjectionLockActive && !getSceneDebug()?.disableZenithBias && !isInTransitionFreezeBand(state.fov) && state.fov > prevFov && state.fov > zenithBiasStartFov) {
4323
+ const range = ENGINE_CONFIG.maxFov - zenithBiasStartFov;
4324
+ let t = (state.fov - zenithBiasStartFov) / range;
4172
4325
  t = Math.max(0, Math.min(1, t));
4173
4326
  const bias = ENGINE_CONFIG.zenithStrength * t;
4174
4327
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -4181,8 +4334,12 @@ function createEngine({
4181
4334
  state.lastMouseX = center.x;
4182
4335
  state.lastMouseY = center.y;
4183
4336
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
4184
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4185
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4337
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4338
+ const massFactor = getMovementMassFactor(state.fov);
4339
+ const moveX = compressInputDelta(deltaX) * massFactor;
4340
+ const moveY = compressInputDelta(deltaY) * massFactor;
4341
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4342
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5 * latFactor;
4186
4343
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
4187
4344
  state.lon = state.targetLon;
4188
4345
  state.lat = state.targetLat;
@@ -4345,7 +4502,9 @@ function createEngine({
4345
4502
  if (inZoneX || inZoneY) {
4346
4503
  if (edgeHoverStart === 0) edgeHoverStart = performance.now();
4347
4504
  if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
4348
- const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
4505
+ const edgeMassFactor = getMovementMassFactor(state.fov, ENGINE_CONFIG.edgePanMassWideFov);
4506
+ const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov) * edgeMassFactor;
4507
+ const verticalPanFactor = getVerticalPanFactor(state.fov, state.lat);
4349
4508
  if (mouseNDC.x < -1 + t) {
4350
4509
  const s = (-1 + t - mouseNDC.x) / t;
4351
4510
  panX = -s * s * speedBase;
@@ -4355,10 +4514,10 @@ function createEngine({
4355
4514
  }
4356
4515
  if (mouseNDC.y < -1 + t) {
4357
4516
  const s = (-1 + t - mouseNDC.y) / t;
4358
- panY = -s * s * speedBase;
4517
+ panY = -s * s * speedBase * verticalPanFactor;
4359
4518
  } else if (mouseNDC.y > 1 - t) {
4360
4519
  const s = (mouseNDC.y - (1 - t)) / t;
4361
- panY = s * s * speedBase;
4520
+ panY = s * s * speedBase * verticalPanFactor;
4362
4521
  }
4363
4522
  }
4364
4523
  } else {
@@ -4390,14 +4549,32 @@ function createEngine({
4390
4549
  state.targetLat = state.lat;
4391
4550
  } else if (!state.isDragging && !flyToActive) {
4392
4551
  state.lon += state.velocityX;
4552
+ state.velocityY *= getVerticalPanFactor(state.fov, state.lat);
4393
4553
  state.lat += state.velocityY;
4394
- const damping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
4554
+ const baseDamping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
4555
+ const speed = Math.hypot(state.velocityX, state.velocityY);
4556
+ const damping = speed < ENGINE_CONFIG.lowSpeedVelocityThreshold ? Math.min(baseDamping, ENGINE_CONFIG.lowSpeedInertiaDamping) : baseDamping;
4395
4557
  state.velocityX *= damping;
4396
4558
  state.velocityY *= damping;
4397
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
4398
- if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
4559
+ if (Math.abs(state.velocityX) < ENGINE_CONFIG.velocityStopThreshold) state.velocityX = 0;
4560
+ if (Math.abs(state.velocityY) < ENGINE_CONFIG.velocityStopThreshold) state.velocityY = 0;
4399
4561
  }
4400
4562
  state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
4563
+ if (!flyToActive) {
4564
+ const latDeg = THREE6__namespace.MathUtils.radToDeg(state.lat);
4565
+ if (latDeg < HORIZON_ZOOM_CONFIG.latStartDeg) {
4566
+ const t = THREE6__namespace.MathUtils.clamp(latDeg / HORIZON_ZOOM_CONFIG.latStartDeg, 0, 1);
4567
+ const maxFov = THREE6__namespace.MathUtils.lerp(
4568
+ HORIZON_ZOOM_CONFIG.safeFovAtHorizon,
4569
+ ENGINE_CONFIG.maxFov,
4570
+ t
4571
+ );
4572
+ if (state.fov > maxFov) {
4573
+ state.fov = THREE6__namespace.MathUtils.lerp(state.fov, maxFov, HORIZON_ZOOM_CONFIG.lerpRate);
4574
+ }
4575
+ }
4576
+ }
4577
+ applyZenithAutoCenter();
4401
4578
  const y = Math.sin(state.lat);
4402
4579
  const r = Math.cos(state.lat);
4403
4580
  const x = r * Math.sin(state.lon);
@@ -4410,11 +4587,13 @@ function createEngine({
4410
4587
  camera.updateMatrixWorld();
4411
4588
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
4412
4589
  if (groundMaterial?.uniforms?.uZenithFlatten) {
4413
- const flatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6__namespace.MathUtils.smoothstep(
4590
+ const targetFlatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6__namespace.MathUtils.smoothstep(
4414
4591
  state.lat,
4415
4592
  THREE6__namespace.MathUtils.degToRad(68),
4416
4593
  THREE6__namespace.MathUtils.degToRad(88)
4417
4594
  );
4595
+ const prevFlatten = Number(groundMaterial.uniforms.uZenithFlatten.value ?? 0);
4596
+ const flatten = isInTransitionFreezeBand(state.fov) ? THREE6__namespace.MathUtils.clamp(targetFlatten, prevFlatten - 0.01, prevFlatten + 0.01) : targetFlatten;
4418
4597
  groundMaterial.uniforms.uZenithFlatten.value = flatten;
4419
4598
  }
4420
4599
  updateUniforms();
@@ -4431,6 +4610,11 @@ function createEngine({
4431
4610
  const baseArtOpacity = THREE6__namespace.MathUtils.clamp(currentConfig?.constellationBaseOpacity ?? 1, 0, 300);
4432
4611
  constellationLayer.setGlobalOpacity?.(artFader.eased * baseArtOpacity);
4433
4612
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
4613
+ const revealZoom = currentConfig?.starZoomReveal ?? true ? THREE6__namespace.MathUtils.clamp(
4614
+ (ZOOM_REVEAL_CONFIG.wideFov - state.fov) / (ZOOM_REVEAL_CONFIG.wideFov - ZOOM_REVEAL_CONFIG.narrowFov),
4615
+ 0,
4616
+ 1
4617
+ ) : 1;
4434
4618
  if (backdropStarsMaterial?.uniforms) {
4435
4619
  const minGain = THREE6__namespace.MathUtils.clamp(currentConfig?.backdropWideFovGain ?? 0.42, 0, 1);
4436
4620
  const fovT = THREE6__namespace.MathUtils.smoothstep(state.fov, 24, 100);
@@ -4438,6 +4622,11 @@ function createEngine({
4438
4622
  backdropStarsMaterial.uniforms.uBackdropGain.value = gain;
4439
4623
  backdropStarsMaterial.uniforms.uBackdropEnergy.value = THREE6__namespace.MathUtils.clamp(currentConfig?.backdropEnergy ?? 2.2, 0.2, 5);
4440
4624
  backdropStarsMaterial.uniforms.uBackdropSizeExp.value = THREE6__namespace.MathUtils.clamp(currentConfig?.backdropSizeExponent ?? 0.9, 0.4, 1.4);
4625
+ backdropStarsMaterial.uniforms.uRevealZoom.value = revealZoom;
4626
+ }
4627
+ if (starPoints?.material) {
4628
+ const sm = starPoints.material;
4629
+ if (sm.uniforms.uRevealZoom) sm.uniforms.uRevealZoom.value = revealZoom;
4441
4630
  }
4442
4631
  if (skyBackgroundMesh) skyBackgroundMesh.visible = currentConfig?.background !== "transparent";
4443
4632
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
@@ -4447,32 +4636,6 @@ function createEngine({
4447
4636
  if (sunDiscMesh) sunDiscMesh.visible = showSun;
4448
4637
  if (sunHaloMesh) sunHaloMesh.visible = showSun;
4449
4638
  if (milkyWayMesh) milkyWayMesh.visible = currentConfig?.showMilkyWay ?? true;
4450
- if (editHoverMesh) {
4451
- const ringMat = editHoverMesh.material;
4452
- const isEditing = currentConfig?.editable ?? false;
4453
- const isDraggingStar = state.dragMode === "node" && state.draggedStarIndex !== -1;
4454
- const hasTarget = isEditing && editHoverTargetPos !== null;
4455
- if (hasTarget) {
4456
- editHoverMesh.position.copy(editHoverTargetPos);
4457
- const pulseBoost = editDropFlash * 1.8;
4458
- const targetAlpha = 0.8 + pulseBoost;
4459
- ringMat.uniforms.uRingAlpha.value = THREE6__namespace.MathUtils.lerp(ringMat.uniforms.uRingAlpha.value, targetAlpha, 0.15);
4460
- const tGold = isDraggingStar ? 1 : editDropFlash;
4461
- const targetColor = new THREE6__namespace.Color(
4462
- THREE6__namespace.MathUtils.lerp(0.55, 1, tGold),
4463
- THREE6__namespace.MathUtils.lerp(0.88, 0.82, tGold),
4464
- THREE6__namespace.MathUtils.lerp(1, 0.18, tGold)
4465
- );
4466
- ringMat.uniforms.uRingColor.value.lerp(targetColor, 0.18);
4467
- const baseSize = isDraggingStar ? 0.075 : 0.06;
4468
- const targetSize = baseSize * (1 + editDropFlash * 0.7);
4469
- ringMat.uniforms.uRingSize.value = THREE6__namespace.MathUtils.lerp(ringMat.uniforms.uRingSize.value, targetSize, 0.18);
4470
- editDropFlash = Math.max(0, editDropFlash - dt * 3);
4471
- } else {
4472
- ringMat.uniforms.uRingAlpha.value = THREE6__namespace.MathUtils.lerp(ringMat.uniforms.uRingAlpha.value, 0, 0.15);
4473
- ringMat.uniforms.uRingSize.value = THREE6__namespace.MathUtils.lerp(ringMat.uniforms.uRingSize.value, 0.06, 0.2);
4474
- }
4475
- }
4476
4639
  if (constellationLines) {
4477
4640
  constellationLines.visible = linesFader.eased > 0.01;
4478
4641
  if (constellationLines.visible && constellationLines.material) {
@@ -4580,12 +4743,6 @@ function createEngine({
4580
4743
  skyBackgroundMesh.material.dispose();
4581
4744
  skyBackgroundMesh = null;
4582
4745
  }
4583
- if (editHoverMesh) {
4584
- scene.remove(editHoverMesh);
4585
- editHoverMesh.geometry.dispose();
4586
- editHoverMesh.material.dispose();
4587
- editHoverMesh = null;
4588
- }
4589
4746
  renderer.dispose();
4590
4747
  renderer.domElement.remove();
4591
4748
  }
@@ -4628,7 +4785,7 @@ function createEngine({
4628
4785
  }
4629
4786
  return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
4630
4787
  }
4631
- var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
4788
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG, HORIZON_ZOOM_CONFIG, ZOOM_REVEAL_CONFIG;
4632
4789
  var init_createEngine = __esm({
4633
4790
  "src/engine/createEngine.ts"() {
4634
4791
  init_layout();
@@ -4643,8 +4800,28 @@ var init_createEngine = __esm({
4643
4800
  defaultFov: 35,
4644
4801
  dragSpeed: 125e-5,
4645
4802
  inertiaDamping: 0.92,
4803
+ lowSpeedInertiaDamping: 0.78,
4804
+ lowSpeedVelocityThreshold: 25e-4,
4805
+ velocityStopThreshold: 4e-5,
4806
+ zoomResistanceWideFov: 0.82,
4807
+ movementMassWideFov: 0.74,
4808
+ edgePanMassWideFov: 0.68,
4809
+ inputCompression: 0.018,
4646
4810
  blendStart: 35,
4647
4811
  blendEnd: 83,
4812
+ freezeBandStartFov: 76,
4813
+ freezeBandEndFov: 84,
4814
+ zenithBiasStartFov: 85,
4815
+ zenithLockBlendEnter: 0.9,
4816
+ zenithLockBlendExit: 0.8,
4817
+ zenithAutoCenterBlendStart: 0.62,
4818
+ zenithAutoCenterBlendEnd: 0.9,
4819
+ zenithAutoCenterMinLerp: 0.012,
4820
+ zenithAutoCenterMaxLerp: 0.16,
4821
+ verticalPanDampStartFov: 72,
4822
+ verticalPanDampEndFov: 96,
4823
+ verticalPanDampLatStartDeg: 45,
4824
+ verticalPanDampLatEndDeg: 82,
4648
4825
  zenithStartFov: 75,
4649
4826
  zenithStrength: 0.15,
4650
4827
  horizonLockStrength: 0.05,
@@ -4671,6 +4848,30 @@ var init_createEngine = __esm({
4671
4848
  pulseDuration: 2,
4672
4849
  delayPerChapter: 0.1
4673
4850
  };
4851
+ HORIZON_ZOOM_CONFIG = {
4852
+ latStartDeg: 20,
4853
+ // coupling is fully off above this elevation
4854
+ safeFovAtHorizon: 60,
4855
+ // max FOV at the horizon (below freeze-band threshold)
4856
+ lerpRate: 0.03
4857
+ // gentle — should feel like a natural breathing-in
4858
+ };
4859
+ ZOOM_REVEAL_CONFIG = {
4860
+ wideFov: 120,
4861
+ // above this FOV, revealZoom = 0 (nothing new revealed)
4862
+ narrowFov: 8,
4863
+ // below this FOV, revealZoom = 1 (everything visible)
4864
+ zoomCurveExp: 1.8,
4865
+ // non-linear curve exponent (try 1.5 – 2.5)
4866
+ chapterRevealMax: 0.5,
4867
+ // faintest chapter star threshold — visible by ~fov 35
4868
+ chapterFeather: 0.1,
4869
+ // smoothstep width for chapter star fade-in
4870
+ backdropRevealStart: 0.4,
4871
+ // backdrop starts appearing at this mappedZoom
4872
+ backdropRevealEnd: 0.65
4873
+ // backdrop fully visible at this mappedZoom
4874
+ };
4674
4875
  }
4675
4876
  });
4676
4877
  var StarMap = react.forwardRef(