@project-skymap/library 0.7.5 → 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
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var THREE5 = require('three');
3
+ var THREE6 = require('three');
4
4
  var react = require('react');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
 
@@ -22,7 +22,7 @@ function _interopNamespace(e) {
22
22
  return Object.freeze(n);
23
23
  }
24
24
 
25
- var THREE5__namespace = /*#__PURE__*/_interopNamespace(THREE5);
25
+ var THREE6__namespace = /*#__PURE__*/_interopNamespace(THREE6);
26
26
 
27
27
  var __defProp = Object.defineProperty;
28
28
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -151,14 +151,14 @@ var init_constellations = __esm({
151
151
  });
152
152
  function lookAt(point, target, up) {
153
153
  const zAxis = target.clone().normalize();
154
- let xAxis = new THREE5__namespace.Vector3().crossVectors(up, zAxis);
154
+ let xAxis = new THREE6__namespace.Vector3().crossVectors(up, zAxis);
155
155
  if (xAxis.lengthSq() < 1e-4) {
156
- xAxis = new THREE5__namespace.Vector3().crossVectors(new THREE5__namespace.Vector3(1, 0, 0), zAxis);
156
+ xAxis = new THREE6__namespace.Vector3().crossVectors(new THREE6__namespace.Vector3(1, 0, 0), zAxis);
157
157
  }
158
158
  xAxis.normalize();
159
- const yAxis = new THREE5__namespace.Vector3().crossVectors(zAxis, xAxis).normalize();
160
- const m = new THREE5__namespace.Matrix4().makeBasis(xAxis, yAxis, zAxis);
161
- const v = new THREE5__namespace.Vector3(point.x, point.y, point.z);
159
+ const yAxis = new THREE6__namespace.Vector3().crossVectors(zAxis, xAxis).normalize();
160
+ const m = new THREE6__namespace.Matrix4().makeBasis(xAxis, yAxis, zAxis);
161
+ const v = new THREE6__namespace.Vector3(point.x, point.y, point.z);
162
162
  v.applyMatrix4(m);
163
163
  v.add(target);
164
164
  return { x: v.x, y: v.y, z: v.z };
@@ -239,7 +239,7 @@ function computeLayoutPositions(model, layout) {
239
239
  const radiusAtY = Math.sqrt(1 - y * y);
240
240
  const x = Math.cos(midAngle) * radiusAtY;
241
241
  const z = Math.sin(midAngle) * radiusAtY;
242
- const labelPos = new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
242
+ const labelPos = new THREE6__namespace.Vector3(x, y, z).multiplyScalar(radius);
243
243
  uDivision.meta.x = labelPos.x;
244
244
  uDivision.meta.y = labelPos.y;
245
245
  uDivision.meta.z = labelPos.z;
@@ -255,7 +255,7 @@ function computeLayoutPositions(model, layout) {
255
255
  const theta = startAngle + t * angleSpan;
256
256
  const x = Math.cos(theta) * radiusAtY;
257
257
  const z = Math.sin(theta) * radiusAtY;
258
- const bookPos = new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
258
+ const bookPos = new THREE6__namespace.Vector3(x, y, z).multiplyScalar(radius);
259
259
  const labelPos = bookPos.clone();
260
260
  labelPos.y += radius * 0.025;
261
261
  labelPos.setLength(radius);
@@ -266,7 +266,7 @@ function computeLayoutPositions(model, layout) {
266
266
  if (chapters.length > 0) {
267
267
  const territoryRadius = radius * 2 / Math.sqrt(books.length * 2) * 0.7;
268
268
  const localPoints = getConstellationLayout(bookKey, chapters.length, territoryRadius);
269
- const up = new THREE5__namespace.Vector3(0, 1, 0);
269
+ const up = new THREE6__namespace.Vector3(0, 1, 0);
270
270
  chapters.forEach((chap, idx) => {
271
271
  const uChap = updatedNodeMap.get(chap.id);
272
272
  const lp = localPoints[idx];
@@ -285,10 +285,10 @@ function computeLayoutPositions(model, layout) {
285
285
  testaments.forEach((t) => {
286
286
  const children = childrenMap.get(t.id) ?? [];
287
287
  if (children.length === 0) return;
288
- const centroid = new THREE5__namespace.Vector3();
288
+ const centroid = new THREE6__namespace.Vector3();
289
289
  children.forEach((c) => {
290
290
  const u = updatedNodeMap.get(c.id);
291
- centroid.add(new THREE5__namespace.Vector3(u.meta.x, u.meta.y, u.meta.z));
291
+ centroid.add(new THREE6__namespace.Vector3(u.meta.x, u.meta.y, u.meta.z));
292
292
  });
293
293
  centroid.divideScalar(children.length);
294
294
  if (centroid.length() > 0.1) {
@@ -409,7 +409,7 @@ float getMaskAlpha() {
409
409
  });
410
410
  function createSmartMaterial(params) {
411
411
  const uniforms = { ...globalUniforms, ...params.uniforms };
412
- return new THREE5__namespace.ShaderMaterial({
412
+ return new THREE6__namespace.ShaderMaterial({
413
413
  uniforms,
414
414
  vertexShader: `
415
415
  ${BLEND_CHUNK}
@@ -423,8 +423,8 @@ function createSmartMaterial(params) {
423
423
  transparent: params.transparent || false,
424
424
  depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
425
425
  depthTest: params.depthTest !== void 0 ? params.depthTest : true,
426
- side: params.side || THREE5__namespace.FrontSide,
427
- blending: params.blending || THREE5__namespace.NormalBlending
426
+ side: params.side || THREE6__namespace.FrontSide,
427
+ blending: params.blending || THREE6__namespace.NormalBlending
428
428
  });
429
429
  }
430
430
  var globalUniforms;
@@ -442,16 +442,102 @@ var init_materials = __esm({
442
442
  uAtmGlow: { value: 1 },
443
443
  uAtmDark: { value: 0.6 },
444
444
  uAtmExtinction: { value: 4 },
445
- uAtmTwinkle: { value: 0.8 },
446
- uColorHorizon: { value: new THREE5__namespace.Color(3825292) },
447
- uColorZenith: { value: new THREE5__namespace.Color(132104) }
445
+ uAtmTwinkle: { value: 0 },
446
+ uColorHorizon: { value: new THREE6__namespace.Color(3825292) },
447
+ uColorZenith: { value: new THREE6__namespace.Color(132104) }
448
448
  };
449
449
  }
450
450
  });
451
+
452
+ // src/engine/fader.ts
453
+ var Fader;
454
+ var init_fader = __esm({
455
+ "src/engine/fader.ts"() {
456
+ Fader = class {
457
+ target = false;
458
+ value = 0;
459
+ duration;
460
+ constructor(duration = 0.3) {
461
+ this.duration = duration;
462
+ }
463
+ update(dt) {
464
+ const goal = this.target ? 1 : 0;
465
+ if (this.value === goal) return;
466
+ const speed = 1 / this.duration;
467
+ const step = speed * dt;
468
+ const diff = goal - this.value;
469
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
470
+ }
471
+ /** Smoothstep-eased value for perceptually smooth transitions */
472
+ get eased() {
473
+ const v = this.value;
474
+ return v * v * (3 - 2 * v);
475
+ }
476
+ };
477
+ }
478
+ });
479
+ function buildSphereQuad(center, rightDir, upDir, halfWidth, halfHeight, domeRadius, subdivisions = 8) {
480
+ const vertsPerSide = subdivisions + 1;
481
+ const vertCount = vertsPerSide * vertsPerSide;
482
+ const positions = new Float32Array(vertCount * 3);
483
+ const uvs = new Float32Array(vertCount * 2);
484
+ const centerNorm = center.clone().normalize();
485
+ const temp = new THREE6__namespace.Vector3();
486
+ const tangent = new THREE6__namespace.Vector3();
487
+ const q = new THREE6__namespace.Quaternion();
488
+ const halfAngleX = Math.atan2(halfWidth, domeRadius);
489
+ const halfAngleY = Math.atan2(halfHeight, domeRadius);
490
+ for (let iy = 0; iy < vertsPerSide; iy++) {
491
+ for (let ix = 0; ix < vertsPerSide; ix++) {
492
+ const idx = iy * vertsPerSide + ix;
493
+ const u = ix / subdivisions;
494
+ const v = iy / subdivisions;
495
+ const angX = (u - 0.5) * 2 * halfAngleX;
496
+ const angY = (v - 0.5) * 2 * halfAngleY;
497
+ tangent.copy(rightDir).multiplyScalar(angX).addScaledVector(upDir, angY);
498
+ const angle = tangent.length();
499
+ if (angle > 1e-5) {
500
+ tangent.normalize();
501
+ q.setFromAxisAngle(tangent, angle);
502
+ temp.copy(centerNorm).applyQuaternion(q).multiplyScalar(domeRadius);
503
+ } else {
504
+ temp.copy(center);
505
+ }
506
+ positions[idx * 3 + 0] = temp.x;
507
+ positions[idx * 3 + 1] = temp.y;
508
+ positions[idx * 3 + 2] = temp.z;
509
+ uvs[idx * 2 + 0] = u;
510
+ uvs[idx * 2 + 1] = 1 - v;
511
+ }
512
+ }
513
+ const indexCount = subdivisions * subdivisions * 6;
514
+ const indices = new Uint16Array(indexCount);
515
+ let ii = 0;
516
+ for (let iy = 0; iy < subdivisions; iy++) {
517
+ for (let ix = 0; ix < subdivisions; ix++) {
518
+ const a = iy * vertsPerSide + ix;
519
+ const b = a + 1;
520
+ const c = a + vertsPerSide;
521
+ const d = c + 1;
522
+ indices[ii++] = a;
523
+ indices[ii++] = c;
524
+ indices[ii++] = b;
525
+ indices[ii++] = b;
526
+ indices[ii++] = c;
527
+ indices[ii++] = d;
528
+ }
529
+ }
530
+ const geometry = new THREE6__namespace.BufferGeometry();
531
+ geometry.setAttribute("position", new THREE6__namespace.BufferAttribute(positions, 3));
532
+ geometry.setAttribute("uv", new THREE6__namespace.BufferAttribute(uvs, 2));
533
+ geometry.setIndex(new THREE6__namespace.BufferAttribute(indices, 1));
534
+ return geometry;
535
+ }
451
536
  var ConstellationArtworkLayer;
452
537
  var init_ConstellationArtworkLayer = __esm({
453
538
  "src/engine/ConstellationArtworkLayer.ts"() {
454
539
  init_materials();
540
+ init_fader();
455
541
  ConstellationArtworkLayer = class {
456
542
  root;
457
543
  items = [];
@@ -459,27 +545,22 @@ var init_ConstellationArtworkLayer = __esm({
459
545
  hoveredId = null;
460
546
  focusedId = null;
461
547
  constructor(root) {
462
- this.textureLoader = new THREE5__namespace.TextureLoader();
548
+ this.textureLoader = new THREE6__namespace.TextureLoader();
463
549
  this.textureLoader.crossOrigin = "anonymous";
464
- this.root = new THREE5__namespace.Group();
550
+ this.root = new THREE6__namespace.Group();
465
551
  this.root.renderOrder = -1;
466
552
  root.add(this.root);
467
553
  }
468
554
  getItems() {
469
555
  return this.items;
470
556
  }
471
- setPosition(id, pos) {
472
- const item = this.items.find((i) => i.config.id === id);
473
- if (item) {
474
- item.mesh.position.copy(pos);
475
- }
476
- }
477
- load(config, getPosition) {
557
+ load(config, getPosition, getOrientationPosition) {
558
+ const getAnchorPos = getOrientationPosition ?? getPosition;
478
559
  this.clear();
479
560
  console.log(`[Constellation] Loading ${config.constellations.length} constellations from ${config.atlasBasePath}`);
480
561
  const basePath = config.atlasBasePath.replace(/\/$/, "");
481
562
  config.constellations.forEach((c) => {
482
- let center = new THREE5__namespace.Vector3();
563
+ let center = new THREE6__namespace.Vector3();
483
564
  let valid = false;
484
565
  let radius = 2e3;
485
566
  const arrPos = getPosition(c.id);
@@ -497,7 +578,7 @@ var init_ConstellationArtworkLayer = __esm({
497
578
  }
498
579
  }
499
580
  } else if (c.center) {
500
- center.set(c.center[0], c.center[1], c.center[2]);
581
+ center.set(c.center[0], c.center[1], 0);
501
582
  valid = true;
502
583
  } else if (c.anchors.length > 0) {
503
584
  const points = [];
@@ -517,100 +598,65 @@ var init_ConstellationArtworkLayer = __esm({
517
598
  }
518
599
  }
519
600
  if (!valid) return;
520
- const normal = center.clone().normalize().negate();
521
- const upVec = center.clone().normalize();
522
- let right = new THREE5__namespace.Vector3(1, 0, 0);
601
+ const centerNorm = center.clone().normalize();
602
+ let rightDir = new THREE6__namespace.Vector3();
603
+ let upDir = new THREE6__namespace.Vector3();
523
604
  if (c.anchors.length >= 2) {
524
- const p0 = getPosition(c.anchors[0]);
525
- const p1 = getPosition(c.anchors[1]);
526
- if (p0 && p1) {
527
- const diff = new THREE5__namespace.Vector3().subVectors(p1, p0);
528
- right.copy(diff).sub(upVec.clone().multiplyScalar(diff.dot(upVec))).normalize();
605
+ const p0 = getAnchorPos(c.anchors[0]);
606
+ const p1 = getAnchorPos(c.anchors[1]);
607
+ if (p0 && p1 && p0.distanceTo(p1) > 1e-3) {
608
+ const diff = new THREE6__namespace.Vector3().subVectors(p1, p0);
609
+ rightDir.copy(diff).sub(centerNorm.clone().multiplyScalar(diff.dot(centerNorm))).normalize();
610
+ upDir.crossVectors(centerNorm, rightDir).normalize();
611
+ rightDir.crossVectors(upDir, centerNorm).normalize();
612
+ } else {
613
+ this._defaultTangentFrame(centerNorm, rightDir, upDir);
529
614
  }
530
615
  } else {
531
- if (Math.abs(upVec.y) > 0.9) right.set(1, 0, 0).cross(upVec).normalize();
532
- else right.set(0, 1, 0).cross(upVec).normalize();
616
+ this._defaultTangentFrame(centerNorm, rightDir, upDir);
617
+ }
618
+ if (c.rotationDeg !== 0) {
619
+ const q = new THREE6__namespace.Quaternion().setFromAxisAngle(centerNorm, THREE6__namespace.MathUtils.degToRad(c.rotationDeg));
620
+ rightDir.applyQuaternion(q);
621
+ upDir.applyQuaternion(q);
533
622
  }
534
- const top = new THREE5__namespace.Vector3().crossVectors(upVec, right).normalize();
535
- right.crossVectors(top, upVec).normalize();
536
- new THREE5__namespace.Matrix4().makeBasis(right, top, normal);
537
- const geometry = new THREE5__namespace.PlaneGeometry(1, 1);
538
623
  let size = c.radius;
539
624
  if (size <= 1) size *= radius;
540
625
  size *= 2;
626
+ const sqrtAspect = Math.sqrt(c.aspectRatio ?? 1);
627
+ const halfWidth = size / 2 * sqrtAspect;
628
+ const halfHeight = size / 2 / sqrtAspect;
629
+ const geometry = buildSphereQuad(center, rightDir, upDir, halfWidth, halfHeight, radius, 8);
541
630
  const texPath = `${basePath}/${c.image}`;
542
- let blending = THREE5__namespace.NormalBlending;
543
- if (c.blend === "additive") blending = THREE5__namespace.AdditiveBlending;
631
+ const blending = c.blend === "additive" ? THREE6__namespace.AdditiveBlending : THREE6__namespace.NormalBlending;
544
632
  const material = createSmartMaterial({
545
633
  uniforms: {
546
634
  uMap: { value: this.textureLoader.load(texPath) },
547
- // Placeholder, updated below
548
- uOpacity: { value: c.opacity },
549
- uSize: { value: size },
550
- uImgRotation: { value: THREE5__namespace.MathUtils.degToRad(c.rotationDeg) },
551
- uImgAspect: { value: c.aspectRatio ?? 1 }
552
- // uScale, uAspect (screen) are injected by createSmartMaterial/globalUniforms
635
+ uOpacity: { value: c.opacity }
553
636
  },
554
637
  vertexShaderBody: `
555
- #ifdef GL_ES
556
- precision highp float;
557
- #endif
558
-
559
- uniform float uSize;
560
- uniform float uImgRotation;
561
- uniform float uImgAspect;
562
-
563
638
  varying vec2 vUv;
564
-
639
+ varying float vClipFade;
565
640
  void main() {
566
641
  vUv = uv;
567
-
568
- // 1. Project Center Point (Proven Method)
569
- vec4 mvCenter = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
570
- vec4 clipCenter = smartProject(mvCenter);
571
-
572
- // 2. Project "Up" Point (World Zenith)
573
- // Transform World Up (0,1,0) to View Space
574
- vec3 viewUpDir = mat3(viewMatrix) * vec3(0.0, 1.0, 0.0);
575
- // Offset center by a significant amount (1000.0) to ensure screen delta
576
- vec4 mvUp = mvCenter + vec4(viewUpDir * 1000.0, 0.0);
577
- vec4 clipUp = smartProject(mvUp);
578
-
579
- // 3. Calculate Horizon Angle
580
- vec2 screenCenter = clipCenter.xy / clipCenter.w;
581
- vec2 screenUp = clipUp.xy / clipUp.w;
582
- vec2 screenDelta = screenUp - screenCenter;
583
-
584
- float horizonAngle = 0.0;
585
- if (length(screenDelta) > 0.001) {
586
- vec2 screenDir = normalize(screenDelta);
587
- horizonAngle = atan(screenDir.y, screenDir.x) - 1.5708; // -90 deg
642
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
643
+
644
+ // Compute clip-boundary fade BEFORE smartProject
645
+ // (Stellarium culls entire constellations near the boundary;
646
+ // we fade per-vertex for smoother transitions)
647
+ vec3 viewDir = normalize(mvPosition.xyz);
648
+ float clipZ;
649
+ if (uProjectionType == 0) {
650
+ clipZ = -0.1;
651
+ } else if (uProjectionType == 1) {
652
+ clipZ = 0.1;
653
+ } else {
654
+ clipZ = mix(-0.1, 0.1, uBlend);
588
655
  }
589
-
590
- // 4. Combine with User Rotation
591
- float finalAngle = uImgRotation + horizonAngle;
592
-
593
- // 5. Billboard Offset
594
- vec2 offset = position.xy;
595
-
596
- float cr = cos(finalAngle);
597
- float sr = sin(finalAngle);
598
- vec2 rotated = vec2(
599
- offset.x * cr - offset.y * sr,
600
- offset.x * sr + offset.y * cr
601
- );
602
-
603
- rotated.x *= uImgAspect;
604
-
605
- float dist = length(mvCenter.xyz);
606
- float scale = (uSize / dist) * uScale;
607
-
608
- rotated *= scale;
609
- rotated.x /= uAspect;
610
-
611
- gl_Position = clipCenter;
612
- gl_Position.xy += rotated * clipCenter.w;
613
-
656
+ // Smooth fade over 0.3 radians before the clip threshold
657
+ vClipFade = smoothstep(clipZ, clipZ - 0.3, viewDir.z);
658
+
659
+ gl_Position = smartProject(mvPosition);
614
660
  vScreenPos = gl_Position.xy / gl_Position.w;
615
661
  }
616
662
  `,
@@ -621,30 +667,50 @@ var init_ConstellationArtworkLayer = __esm({
621
667
  uniform sampler2D uMap;
622
668
  uniform float uOpacity;
623
669
  varying vec2 vUv;
670
+ varying float vClipFade;
624
671
  void main() {
625
672
  float mask = getMaskAlpha();
626
673
  if (mask < 0.01) discard;
627
674
  vec4 tex = texture2D(uMap, vUv);
628
- gl_FragColor = vec4(tex.rgb, tex.a * uOpacity * mask);
675
+
676
+ // Apply a slight blue tinge to the artwork
677
+ vec3 color = tex.rgb * vec3(0.8, 0.9, 1.0);
678
+
679
+ // vClipFade smoothly hides vertices near the projection
680
+ // clip boundary, preventing mesh distortion from escape positions
681
+ gl_FragColor = vec4(color, tex.a * uOpacity * mask * vClipFade);
629
682
  }
630
683
  `,
631
684
  transparent: true,
632
685
  depthWrite: false,
633
686
  depthTest: true,
634
687
  blending,
635
- side: THREE5__namespace.DoubleSide
688
+ side: THREE6__namespace.DoubleSide
636
689
  });
637
690
  material.uniforms.uMap.value = this.textureLoader.load(
638
691
  texPath,
639
692
  (tex) => {
640
- tex.minFilter = THREE5__namespace.LinearFilter;
641
- tex.magFilter = THREE5__namespace.LinearFilter;
693
+ tex.minFilter = THREE6__namespace.LinearFilter;
694
+ tex.magFilter = THREE6__namespace.LinearFilter;
642
695
  tex.generateMipmaps = false;
643
696
  tex.needsUpdate = true;
644
697
  if (c.aspectRatio === void 0 && tex.image.width && tex.image.height) {
645
698
  const natAspect = tex.image.width / tex.image.height;
646
- material.uniforms.uImgAspect.value = natAspect;
699
+ const sqrtNatAspect = Math.sqrt(natAspect);
700
+ const newHalfWidth = size / 2 * sqrtNatAspect;
701
+ const newHalfHeight = size / 2 / sqrtNatAspect;
702
+ const newGeometry = buildSphereQuad(center, rightDir, upDir, newHalfWidth, newHalfHeight, radius, 8);
703
+ const item2 = this.items.find((i) => i.config.id === c.id);
704
+ if (item2) {
705
+ item2.mesh.geometry.dispose();
706
+ item2.mesh.geometry = newGeometry;
707
+ item2.halfWidth = newHalfWidth;
708
+ item2.halfHeight = newHalfHeight;
709
+ item2.imageLoadedFader.target = true;
710
+ }
647
711
  }
712
+ const item = this.items.find((i) => i.config.id === c.id);
713
+ if (item) item.imageLoadedFader.target = true;
648
714
  console.log(`[Constellation] Loaded: ${c.id} (${tex.image.width}x${tex.image.height})`);
649
715
  },
650
716
  (progress) => {
@@ -657,23 +723,55 @@ var init_ConstellationArtworkLayer = __esm({
657
723
  material.polygonOffset = true;
658
724
  material.polygonOffsetFactor = -c.zBias;
659
725
  }
660
- const mesh = new THREE5__namespace.Mesh(geometry, material);
726
+ const mesh = new THREE6__namespace.Mesh(geometry, material);
661
727
  mesh.frustumCulled = false;
662
728
  mesh.userData = { id: c.id, type: "constellation" };
663
- mesh.position.copy(center);
664
729
  this.root.add(mesh);
665
- this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
730
+ this.items.push({
731
+ config: c,
732
+ mesh,
733
+ material,
734
+ baseOpacity: c.opacity,
735
+ center: center.clone(),
736
+ rightDir: rightDir.clone(),
737
+ upDir: upDir.clone(),
738
+ halfWidth,
739
+ halfHeight,
740
+ domeRadius: radius,
741
+ visibleFader: new Fader(0.35),
742
+ imageLoadedFader: new Fader(0.5)
743
+ });
666
744
  });
667
745
  }
746
+ _defaultTangentFrame(centerNorm, rightDir, upDir) {
747
+ const worldUp = new THREE6__namespace.Vector3(0, 1, 0);
748
+ if (Math.abs(centerNorm.dot(worldUp)) > 0.99) {
749
+ rightDir.crossVectors(new THREE6__namespace.Vector3(1, 0, 0), centerNorm).normalize();
750
+ } else {
751
+ rightDir.crossVectors(worldUp, centerNorm).normalize();
752
+ }
753
+ upDir.crossVectors(centerNorm, rightDir).normalize();
754
+ rightDir.crossVectors(upDir, centerNorm).normalize();
755
+ }
668
756
  _globalOpacity = 1;
669
757
  setGlobalOpacity(v) {
670
758
  this._globalOpacity = v;
671
759
  }
672
- update(fov, showArt) {
760
+ /**
761
+ * Update visibility and opacity.
762
+ * Accepts an optional camera for Stellarium-style visibility culling:
763
+ * constellations whose center is near or past the projection clip
764
+ * boundary are hidden to prevent mesh distortion from escape positions.
765
+ */
766
+ update(fov, showArt, camera, dt = 0.016) {
673
767
  this.root.visible = showArt;
674
768
  if (!showArt) {
675
769
  return;
676
770
  }
771
+ let cameraForward = null;
772
+ if (camera) {
773
+ cameraForward = new THREE6__namespace.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
774
+ }
677
775
  for (const item of this.items) {
678
776
  const { fade } = item.config;
679
777
  let opacity = fade.maxOpacity;
@@ -683,10 +781,30 @@ var init_ConstellationArtworkLayer = __esm({
683
781
  opacity = fade.minOpacity;
684
782
  } else {
685
783
  const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
686
- opacity = THREE5__namespace.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
784
+ opacity = THREE6__namespace.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
687
785
  }
688
- opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
786
+ const halfAngleX = Math.atan2(item.halfWidth, item.domeRadius);
787
+ const halfAngleY = Math.atan2(item.halfHeight, item.domeRadius);
788
+ const diameterDeg = Math.max(halfAngleX, halfAngleY) * 2 * THREE6__namespace.MathUtils.RAD2DEG;
789
+ const zoomFade = THREE6__namespace.MathUtils.smoothstep(fov, diameterDeg / 5, diameterDeg / 2);
790
+ opacity *= zoomFade;
791
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity * item.baseOpacity;
792
+ if (cameraForward) {
793
+ const centerDir = item.center.clone().normalize();
794
+ const dot = cameraForward.dot(centerDir);
795
+ const angle = Math.acos(THREE6__namespace.MathUtils.clamp(dot, -1, 1));
796
+ const angularRadius = Math.max(halfAngleX, halfAngleY);
797
+ const margin = THREE6__namespace.MathUtils.degToRad(8);
798
+ item.visibleFader.target = angle <= Math.PI * 0.5 + angularRadius + margin;
799
+ } else {
800
+ item.visibleFader.target = true;
801
+ }
802
+ item.visibleFader.update(dt);
803
+ opacity *= item.visibleFader.eased;
804
+ item.imageLoadedFader.update(dt);
805
+ opacity *= item.imageLoadedFader.eased;
689
806
  item.material.uniforms.uOpacity.value = opacity;
807
+ item.mesh.visible = opacity > 1e-3;
690
808
  }
691
809
  }
692
810
  setHovered(id) {
@@ -783,6 +901,7 @@ var init_projections = __esm({
783
901
  blendEnd;
784
902
  /** Current blend factor, updated via setFov() */
785
903
  blend = 0;
904
+ blendOverride = null;
786
905
  constructor(blendStart = 40, blendEnd = 100) {
787
906
  this.blendStart = blendStart;
788
907
  this.blendEnd = blendEnd;
@@ -801,14 +920,22 @@ var init_projections = __esm({
801
920
  this.blend = t * t * (3 - 2 * t);
802
921
  }
803
922
  getBlend() {
804
- return this.blend;
923
+ return this.blendOverride ?? this.blend;
924
+ }
925
+ setBlendOverride(value) {
926
+ if (value === null || value === void 0 || Number.isNaN(value)) {
927
+ this.blendOverride = null;
928
+ return;
929
+ }
930
+ this.blendOverride = Math.max(0, Math.min(1, value));
805
931
  }
806
932
  forward(dir) {
807
- if (this.blend > 0.5 && dir.z > 0.4) return null;
808
- if (this.blend < 0.1 && dir.z > -0.1) return null;
933
+ const b = this.getBlend();
934
+ if (b > 0.5 && dir.z > 0.4) return null;
935
+ if (b < 0.1 && dir.z > -0.1) return null;
809
936
  const kLinear = 1 / Math.max(0.01, -dir.z);
810
937
  const kStereo = 2 / (1 - dir.z);
811
- const k = kLinear * (1 - this.blend) + kStereo * this.blend;
938
+ const k = kLinear * (1 - b) + kStereo * b;
812
939
  return { x: k * dir.x, y: k * dir.y, z: dir.z };
813
940
  }
814
941
  inverse(uvX, uvY, fovRad) {
@@ -817,7 +944,8 @@ var init_projections = __esm({
817
944
  const thetaLin = Math.atan(r * halfHeightLin);
818
945
  const halfHeightStereo = 2 * Math.tan(fovRad / 4);
819
946
  const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
820
- const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
947
+ const b = this.getBlend();
948
+ const theta = thetaLin * (1 - b) + thetaStereo * b;
821
949
  const phi = Math.atan2(uvY, uvX);
822
950
  const sinT = Math.sin(theta);
823
951
  return {
@@ -829,11 +957,13 @@ var init_projections = __esm({
829
957
  getScale(fovRad) {
830
958
  const scaleLinear = 1 / Math.tan(fovRad / 2);
831
959
  const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
832
- return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
960
+ const b = this.getBlend();
961
+ return scaleLinear * (1 - b) + scaleStereo * b;
833
962
  }
834
963
  isClipped(dirZ) {
835
- if (this.blend > 0.5) return dirZ > 0.4;
836
- if (this.blend < 0.1) return dirZ > -0.1;
964
+ const b = this.getBlend();
965
+ if (b > 0.5) return dirZ > 0.4;
966
+ if (b < 0.1) return dirZ > -0.1;
837
967
  return false;
838
968
  }
839
969
  };
@@ -843,30 +973,366 @@ var init_projections = __esm({
843
973
  };
844
974
  }
845
975
  });
846
-
847
- // src/engine/fader.ts
848
- var Fader;
849
- var init_fader = __esm({
850
- "src/engine/fader.ts"() {
851
- Fader = class {
852
- target = false;
853
- value = 0;
854
- duration;
855
- constructor(duration = 0.3) {
856
- this.duration = duration;
976
+ function levelToClass(level) {
977
+ if (level === 1) return "division";
978
+ if (level === 2) return "book";
979
+ if (level === 2.5) return "group";
980
+ return "chapter";
981
+ }
982
+ function isClassEnabled(classKey, toggles) {
983
+ if (classKey === "division") return toggles.showDivisionLabels;
984
+ if (classKey === "book") return toggles.showBookLabels;
985
+ if (classKey === "group") return toggles.showGroupLabels;
986
+ return toggles.showChapterLabels;
987
+ }
988
+ function lerp(a, b, t) {
989
+ return a + (b - a) * t;
990
+ }
991
+ function resolveLabelBehavior(config) {
992
+ const classes = { ...DEFAULT_LABEL_BEHAVIOR.classes };
993
+ const mergeClass = (key, source) => {
994
+ const base = DEFAULT_LABEL_BEHAVIOR.classes[key];
995
+ return {
996
+ minFov: source?.minFov ?? base.minFov,
997
+ maxFov: source?.maxFov ?? base.maxFov,
998
+ priority: source?.priority ?? base.priority,
999
+ mode: source?.mode ?? base.mode,
1000
+ maxOverlapPx: source?.maxOverlapPx ?? base.maxOverlapPx,
1001
+ radialFadeStart: source?.radialFadeStart ?? base.radialFadeStart,
1002
+ radialFadeEnd: source?.radialFadeEnd ?? base.radialFadeEnd,
1003
+ fadeDuration: source?.fadeDuration ?? base.fadeDuration
1004
+ };
1005
+ };
1006
+ classes.division = mergeClass("division", config?.classes?.division);
1007
+ classes.book = mergeClass("book", config?.classes?.book);
1008
+ classes.group = mergeClass("group", config?.classes?.group);
1009
+ classes.chapter = mergeClass("chapter", config?.classes?.chapter);
1010
+ return {
1011
+ hideBackFacing: config?.hideBackFacing ?? DEFAULT_LABEL_BEHAVIOR.hideBackFacing,
1012
+ overlapPaddingPx: config?.overlapPaddingPx ?? DEFAULT_LABEL_BEHAVIOR.overlapPaddingPx,
1013
+ reappearDelayMs: config?.reappearDelayMs ?? DEFAULT_LABEL_BEHAVIOR.reappearDelayMs,
1014
+ classes
1015
+ };
1016
+ }
1017
+ function boundsOverlapDepth(a, b) {
1018
+ const ix0 = Math.max(a.x, b.x);
1019
+ const iy0 = Math.max(a.y, b.y);
1020
+ const ix1 = Math.min(a.x + a.w, b.x + b.w);
1021
+ const iy1 = Math.min(a.y + a.h, b.y + b.h);
1022
+ if (ix1 <= ix0 || iy1 <= iy0) return 0;
1023
+ return Math.min(ix1 - ix0, iy1 - iy0);
1024
+ }
1025
+ function boundsDistPoint(rect, px, py) {
1026
+ const cx = rect.x + rect.w * 0.5;
1027
+ const cy = rect.y + rect.h * 0.5;
1028
+ const dx = Math.max(Math.abs(px - cx) - rect.w * 0.5, 0);
1029
+ const dy = Math.max(Math.abs(py - cy) - rect.h * 0.5, 0);
1030
+ return Math.sqrt(dx * dx + dy * dy);
1031
+ }
1032
+ function getLabelUniforms(obj) {
1033
+ const material = obj.material;
1034
+ if (!(material instanceof THREE6__namespace.ShaderMaterial) || !material.uniforms) return null;
1035
+ const uniforms = material.uniforms;
1036
+ const uSize = uniforms.uSize;
1037
+ const uAlpha = uniforms.uAlpha;
1038
+ const uAngle = uniforms.uAngle;
1039
+ if (!uSize || !(uSize.value instanceof THREE6__namespace.Vector2) || !uAlpha || typeof uAlpha.value !== "number") {
1040
+ return null;
1041
+ }
1042
+ return {
1043
+ uSize: { value: uSize.value },
1044
+ uAlpha: { value: uAlpha.value },
1045
+ uAngle: uAngle && typeof uAngle.value === "number" ? { value: uAngle.value } : void 0
1046
+ };
1047
+ }
1048
+ function applyUniformAlpha(obj, alpha, angle) {
1049
+ const material = obj.material;
1050
+ if (!(material instanceof THREE6__namespace.ShaderMaterial) || !material.uniforms) return;
1051
+ const uniforms = material.uniforms;
1052
+ if (uniforms.uAlpha && typeof uniforms.uAlpha.value === "number") {
1053
+ uniforms.uAlpha.value = alpha;
1054
+ }
1055
+ }
1056
+ var DEFAULT_LABEL_BEHAVIOR, LabelManager;
1057
+ var init_LabelManager = __esm({
1058
+ "src/engine/LabelManager.ts"() {
1059
+ init_fader();
1060
+ DEFAULT_LABEL_BEHAVIOR = {
1061
+ hideBackFacing: true,
1062
+ overlapPaddingPx: 2,
1063
+ reappearDelayMs: 60,
1064
+ classes: {
1065
+ division: {
1066
+ minFov: 55,
1067
+ maxFov: 180,
1068
+ priority: 70,
1069
+ mode: "floating",
1070
+ maxOverlapPx: 10,
1071
+ radialFadeStart: 1,
1072
+ radialFadeEnd: 1.2,
1073
+ fadeDuration: 0.28
1074
+ },
1075
+ book: {
1076
+ minFov: 0,
1077
+ maxFov: 22,
1078
+ priority: 60,
1079
+ mode: "pinned",
1080
+ maxOverlapPx: 999,
1081
+ radialFadeStart: 1,
1082
+ radialFadeEnd: 1.2,
1083
+ fadeDuration: 0.22
1084
+ },
1085
+ group: {
1086
+ minFov: 0,
1087
+ maxFov: 22,
1088
+ priority: 42,
1089
+ mode: "pinned",
1090
+ maxOverlapPx: 999,
1091
+ radialFadeStart: 1,
1092
+ radialFadeEnd: 1.2,
1093
+ fadeDuration: 0.22
1094
+ },
1095
+ chapter: {
1096
+ minFov: 0,
1097
+ maxFov: 22,
1098
+ priority: 30,
1099
+ mode: "pinned",
1100
+ maxOverlapPx: 999,
1101
+ radialFadeStart: 0.55,
1102
+ radialFadeEnd: 0.95,
1103
+ fadeDuration: 0.16
1104
+ }
857
1105
  }
858
- update(dt) {
859
- const goal = this.target ? 1 : 0;
860
- if (this.value === goal) return;
861
- const speed = 1 / this.duration;
862
- const step = speed * dt;
863
- const diff = goal - this.value;
864
- this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
1106
+ };
1107
+ LabelManager = class {
1108
+ records = /* @__PURE__ */ new Map();
1109
+ setLabels(labels) {
1110
+ const activeIds = /* @__PURE__ */ new Set();
1111
+ for (const label of labels) {
1112
+ activeIds.add(label.node.id);
1113
+ const existing = this.records.get(label.node.id);
1114
+ if (existing) {
1115
+ existing.label = label;
1116
+ existing.classKey = levelToClass(label.node.level);
1117
+ continue;
1118
+ }
1119
+ this.records.set(label.node.id, {
1120
+ id: label.node.id,
1121
+ label,
1122
+ fader: new Fader(0.2),
1123
+ classKey: levelToClass(label.node.level),
1124
+ lastRejectedAtMs: 0,
1125
+ lastAccepted: false,
1126
+ targetAlpha: 0
1127
+ });
1128
+ }
1129
+ for (const [id] of this.records) {
1130
+ if (!activeIds.has(id)) {
1131
+ this.records.delete(id);
1132
+ }
1133
+ }
865
1134
  }
866
- /** Smoothstep-eased value for perceptually smooth transitions */
867
- get eased() {
868
- const v = this.value;
869
- return v * v * (3 - 2 * v);
1135
+ clear() {
1136
+ this.records.clear();
1137
+ }
1138
+ update(ctx) {
1139
+ const behavior = resolveLabelBehavior(ctx.config);
1140
+ const candidates = [];
1141
+ const cameraForward = new THREE6__namespace.Vector3(0, 0, -1).applyQuaternion(ctx.camera.quaternion);
1142
+ for (const record of this.records.values()) {
1143
+ const classBehavior = behavior.classes[record.classKey];
1144
+ record.fader.duration = classBehavior.fadeDuration;
1145
+ const isEnabled = isClassEnabled(record.classKey, ctx.toggles);
1146
+ const isSpecial = record.id === ctx.selectedId || record.id === ctx.hoverId || record.id === ctx.focusedId;
1147
+ let targetAlpha = 0;
1148
+ let angleTarget;
1149
+ if (isEnabled) {
1150
+ const maxFov = classBehavior.maxFov + (record.label.maxFovBias ?? 0);
1151
+ const inFovRange = ctx.fov >= classBehavior.minFov && ctx.fov <= maxFov;
1152
+ if (inFovRange || isSpecial) {
1153
+ const pWorld = record.label.obj.position;
1154
+ const pProj = ctx.project(pWorld);
1155
+ const frontVisible = pProj.z <= 0.2;
1156
+ let backFacing = false;
1157
+ if (behavior.hideBackFacing) {
1158
+ const worldDir = pWorld.clone().normalize();
1159
+ backFacing = worldDir.dot(cameraForward) < -0.2;
1160
+ }
1161
+ if (frontVisible && !backFacing) {
1162
+ const ndcX = pProj.x * ctx.globalScale / ctx.aspect;
1163
+ const ndcY = pProj.y * ctx.globalScale;
1164
+ const sX = (ndcX * 0.5 + 0.5) * ctx.screenW;
1165
+ const sY = (-ndcY * 0.5 + 0.5) * ctx.screenH;
1166
+ const uniforms = getLabelUniforms(record.label.obj);
1167
+ if (uniforms) {
1168
+ const pixelH = uniforms.uSize.value.y * ctx.screenH * 0.8;
1169
+ const pixelW = uniforms.uSize.value.x * ctx.screenH * 0.8;
1170
+ targetAlpha = 1;
1171
+ if (targetAlpha > 0 && record.classKey === "chapter" && !isSpecial) {
1172
+ const dist = Math.sqrt(ndcX * ndcX + ndcY * ndcY);
1173
+ const fovWeight = THREE6__namespace.MathUtils.smoothstep(ctx.fov, 10, 40);
1174
+ const focusOuter = THREE6__namespace.MathUtils.lerp(0.82, 0.62, fovWeight);
1175
+ const focusInner = THREE6__namespace.MathUtils.lerp(0.24, 0.16, fovWeight);
1176
+ const centerFocus = 1 - THREE6__namespace.MathUtils.smoothstep(dist, focusInner, focusOuter);
1177
+ const chapterVisibility = THREE6__namespace.MathUtils.lerp(1, centerFocus, fovWeight);
1178
+ targetAlpha *= chapterVisibility;
1179
+ if (dist > focusOuter && ctx.fov > 12) {
1180
+ targetAlpha = 0;
1181
+ }
1182
+ if (dist < 0.18 && ctx.fov < 58) {
1183
+ targetAlpha = Math.max(targetAlpha, 0.55);
1184
+ }
1185
+ }
1186
+ if (targetAlpha > 0 && (record.classKey === "book" || record.classKey === "group") && !isSpecial) {
1187
+ const dist = Math.sqrt(ndcX * ndcX + ndcY * ndcY);
1188
+ const fovWeight = THREE6__namespace.MathUtils.smoothstep(ctx.fov, 15, 58);
1189
+ const focusOuter = THREE6__namespace.MathUtils.lerp(0.95, 0.7, fovWeight);
1190
+ const focusInner = THREE6__namespace.MathUtils.lerp(0.35, 0.22, fovWeight);
1191
+ const centerFocus = 1 - THREE6__namespace.MathUtils.smoothstep(dist, focusInner, focusOuter);
1192
+ const bookVisibility = THREE6__namespace.MathUtils.lerp(1, centerFocus, fovWeight);
1193
+ targetAlpha *= bookVisibility;
1194
+ if (dist > focusOuter && ctx.fov > 20) {
1195
+ targetAlpha = 0;
1196
+ }
1197
+ }
1198
+ if (targetAlpha > 0 && ctx.shouldFilter) {
1199
+ const node = record.label.node;
1200
+ if (node.level === 3) {
1201
+ targetAlpha = 0;
1202
+ } else if (node.level === 2 || node.level === 2.5) {
1203
+ if (ctx.isNodeFiltered(node)) targetAlpha = 0;
1204
+ }
1205
+ }
1206
+ if (targetAlpha > 0 && record.classKey === "chapter" && record.label.chapterStarWorldPos) {
1207
+ const starProj = ctx.project(record.label.chapterStarWorldPos);
1208
+ if (starProj.z <= 0.2) {
1209
+ const starNdcX = starProj.x * ctx.globalScale / ctx.aspect;
1210
+ const starNdcY = starProj.y * ctx.globalScale;
1211
+ const starSX = (starNdcX * 0.5 + 0.5) * ctx.screenW;
1212
+ const starSY = (-starNdcY * 0.5 + 0.5) * ctx.screenH;
1213
+ const rect = {
1214
+ x: sX - pixelW / 2,
1215
+ y: sY - pixelH / 2,
1216
+ w: pixelW,
1217
+ h: pixelH,
1218
+ priority: classBehavior.priority
1219
+ };
1220
+ const glowRadiusPx = record.label.chapterGlowRadiusPx ?? 18;
1221
+ const clearancePx = Math.max(1, glowRadiusPx * 0.02);
1222
+ const distToLabel = boundsDistPoint(rect, starSX, starSY);
1223
+ if (glowRadiusPx >= 70) {
1224
+ const threshold = glowRadiusPx + clearancePx;
1225
+ const visibility = THREE6__namespace.MathUtils.smoothstep(distToLabel, threshold - 4, threshold + 2);
1226
+ const lowFovRelief = 1 - THREE6__namespace.MathUtils.smoothstep(ctx.fov, 8, 18);
1227
+ const boostedVisibility = isSpecial ? Math.max(visibility, 0.92) : Math.max(visibility, lowFovRelief * 0.85);
1228
+ targetAlpha *= boostedVisibility;
1229
+ }
1230
+ }
1231
+ }
1232
+ if (record.classKey === "division" && uniforms.uAngle) {
1233
+ angleTarget = 0;
1234
+ if (ctx.projectionId !== "perspective") {
1235
+ const dx = sX - ctx.screenW / 2;
1236
+ const dy = sY - ctx.screenH / 2;
1237
+ angleTarget = Math.atan2(-dy, -dx) - Math.PI / 2;
1238
+ }
1239
+ }
1240
+ if (targetAlpha > 0) {
1241
+ const priorityBoost = isSpecial ? record.id === ctx.selectedId ? 400 : 300 : 0;
1242
+ candidates.push({
1243
+ record,
1244
+ behavior: classBehavior,
1245
+ uniforms,
1246
+ sX,
1247
+ sY,
1248
+ w: pixelW,
1249
+ h: pixelH,
1250
+ ndcX,
1251
+ ndcY,
1252
+ priority: classBehavior.priority + priorityBoost,
1253
+ isPinned: isSpecial || classBehavior.mode === "pinned",
1254
+ isSpecial,
1255
+ centerDist: Math.sqrt((sX - ctx.screenW * 0.5) ** 2 + (sY - ctx.screenH * 0.5) ** 2)
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+ if (typeof angleTarget === "number") {
1263
+ const material = record.label.obj.material;
1264
+ if (material instanceof THREE6__namespace.ShaderMaterial && material.uniforms.uAngle && typeof material.uniforms.uAngle.value === "number") {
1265
+ const current = material.uniforms.uAngle.value;
1266
+ material.uniforms.uAngle.value = lerp(current, angleTarget, 0.1);
1267
+ }
1268
+ }
1269
+ record.targetAlpha = targetAlpha;
1270
+ }
1271
+ candidates.sort((a, b) => {
1272
+ if (a.priority !== b.priority) return b.priority - a.priority;
1273
+ if (a.record.lastAccepted !== b.record.lastAccepted) return a.record.lastAccepted ? -1 : 1;
1274
+ return a.centerDist - b.centerDist;
1275
+ });
1276
+ let guaranteedCenterChapterId = null;
1277
+ if (ctx.toggles.showChapterLabels && ctx.fov <= behavior.classes.chapter.maxFov + 15) {
1278
+ const centerChapter = candidates.filter((c) => c.record.classKey === "chapter" && c.record.targetAlpha > 0).sort((a, b) => a.centerDist - b.centerDist)[0];
1279
+ if (centerChapter) {
1280
+ guaranteedCenterChapterId = centerChapter.record.id;
1281
+ centerChapter.record.targetAlpha = Math.max(centerChapter.record.targetAlpha, 0.85);
1282
+ }
1283
+ }
1284
+ const occupied = [];
1285
+ const accepted = /* @__PURE__ */ new Set();
1286
+ for (const c of candidates) {
1287
+ if (c.record.targetAlpha <= 0) continue;
1288
+ const rect = {
1289
+ x: c.sX - c.w / 2 - behavior.overlapPaddingPx,
1290
+ y: c.sY - c.h / 2 - behavior.overlapPaddingPx,
1291
+ w: c.w + behavior.overlapPaddingPx * 2,
1292
+ h: c.h + behavior.overlapPaddingPx * 2,
1293
+ priority: c.priority
1294
+ };
1295
+ let rejected = false;
1296
+ const isGuaranteedCenterChapter = c.record.id === guaranteedCenterChapterId;
1297
+ if (!c.isPinned && !isGuaranteedCenterChapter) {
1298
+ if (!c.record.lastAccepted && !c.isSpecial && c.record.lastRejectedAtMs > 0) {
1299
+ const sinceReject = ctx.nowMs - c.record.lastRejectedAtMs;
1300
+ if (sinceReject < behavior.reappearDelayMs) {
1301
+ rejected = true;
1302
+ }
1303
+ }
1304
+ if (!rejected) {
1305
+ for (const other of occupied) {
1306
+ if (other.priority < c.priority) continue;
1307
+ const overlapDepth = boundsOverlapDepth(rect, other);
1308
+ if (overlapDepth > c.behavior.maxOverlapPx) {
1309
+ rejected = true;
1310
+ break;
1311
+ }
1312
+ }
1313
+ }
1314
+ }
1315
+ if (rejected) {
1316
+ c.record.lastAccepted = false;
1317
+ c.record.lastRejectedAtMs = ctx.nowMs;
1318
+ continue;
1319
+ }
1320
+ occupied.push(rect);
1321
+ accepted.add(c.record.id);
1322
+ c.record.lastAccepted = true;
1323
+ }
1324
+ for (const record of this.records.values()) {
1325
+ const acceptedThisFrame = accepted.has(record.id) && record.targetAlpha > 0;
1326
+ record.fader.target = acceptedThisFrame;
1327
+ record.fader.update(ctx.dt);
1328
+ const baseAlpha = acceptedThisFrame ? record.targetAlpha : record.targetAlpha > 0 ? 1 : 0;
1329
+ const alpha = record.fader.eased * baseAlpha;
1330
+ applyUniformAlpha(record.label.obj, alpha);
1331
+ record.label.obj.visible = alpha > 0.01;
1332
+ if (!acceptedThisFrame) {
1333
+ record.lastAccepted = false;
1334
+ }
1335
+ }
870
1336
  }
871
1337
  };
872
1338
  }
@@ -893,6 +1359,7 @@ function createEngine({
893
1359
  }) {
894
1360
  let hoveredBookId = null;
895
1361
  let focusedBookId = null;
1362
+ let focusedNodeId = null;
896
1363
  let orderRevealEnabled = true;
897
1364
  let activeBookIndex = -1;
898
1365
  let orderRevealStrength = 0;
@@ -911,26 +1378,15 @@ function createEngine({
911
1378
  const bookIdToIndex = /* @__PURE__ */ new Map();
912
1379
  const testamentToIndex = /* @__PURE__ */ new Map();
913
1380
  const divisionToIndex = /* @__PURE__ */ new Map();
914
- const renderer = new THREE5__namespace.WebGLRenderer({ antialias: true, alpha: false });
1381
+ const renderer = new THREE6__namespace.WebGLRenderer({ antialias: true, alpha: false });
915
1382
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
916
1383
  renderer.setSize(container.clientWidth, container.clientHeight);
917
1384
  container.appendChild(renderer.domElement);
918
- const scene = new THREE5__namespace.Scene();
919
- scene.background = new THREE5__namespace.Color(0);
920
- const camera = new THREE5__namespace.PerspectiveCamera(60, 1, 0.1, 1e4);
1385
+ const scene = new THREE6__namespace.Scene();
1386
+ scene.background = null;
1387
+ const camera = new THREE6__namespace.PerspectiveCamera(60, 1, 0.1, 1e4);
921
1388
  camera.position.set(0, 0, 0);
922
1389
  camera.up.set(0, 1, 0);
923
- function setHoveredBook(id) {
924
- if (id === hoveredBookId) return;
925
- const now = performance.now();
926
- if (hoveredBookId) {
927
- hoverCooldowns.set(hoveredBookId, now);
928
- }
929
- if (id) {
930
- hoverCooldowns.get(id) || 0;
931
- }
932
- hoveredBookId = id;
933
- }
934
1390
  let running = false;
935
1391
  let raf = 0;
936
1392
  const state = {
@@ -968,21 +1424,120 @@ function createEngine({
968
1424
  longPressTimer: null,
969
1425
  longPressTriggered: false
970
1426
  };
971
- const mouseNDC = new THREE5__namespace.Vector2();
1427
+ const mouseNDC = new THREE6__namespace.Vector2();
972
1428
  let isMouseInWindow = false;
973
1429
  let isTouchDevice = false;
974
1430
  let edgeHoverStart = 0;
975
1431
  let handlers = { onSelect, onHover, onArrangementChange, onFovChange, onLongPress };
976
1432
  let currentConfig;
1433
+ function getSceneDebug() {
1434
+ return currentConfig?.debug?.sceneMechanics;
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
+ }
977
1485
  const constellationLayer = new ConstellationArtworkLayer(scene);
978
1486
  function mix(a, b, t) {
979
1487
  return a * (1 - t) + b * t;
980
1488
  }
981
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
+ }
982
1530
  function syncProjectionState() {
983
1531
  if (currentProjection instanceof BlendedProjection) {
984
1532
  currentProjection.setFov(state.fov);
1533
+ currentProjection.setBlendOverride(getSceneDebug()?.projectionBlendOverride ?? null);
985
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;
986
1541
  }
987
1542
  globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
988
1543
  }
@@ -1009,7 +1564,7 @@ function createEngine({
1009
1564
  const uvX = mouseNDC.x * aspectRatio;
1010
1565
  const uvY = mouseNDC.y;
1011
1566
  const v = currentProjection.inverse(uvX, uvY, fovRad);
1012
- return new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
1567
+ return new THREE6__namespace.Vector3(v.x, v.y, v.z).normalize();
1013
1568
  }
1014
1569
  function getMouseWorldVector(pixelX, pixelY, width, height) {
1015
1570
  const aspect = width / height;
@@ -1018,7 +1573,7 @@ function createEngine({
1018
1573
  syncProjectionState();
1019
1574
  const fovRad = state.fov * Math.PI / 180;
1020
1575
  const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
1021
- const vView = new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
1576
+ const vView = new THREE6__namespace.Vector3(v.x, v.y, v.z).normalize();
1022
1577
  return vView.applyQuaternion(camera.quaternion);
1023
1578
  }
1024
1579
  function smartProjectJS(worldPos) {
@@ -1028,135 +1583,871 @@ function createEngine({
1028
1583
  if (!result) return { x: 0, y: 0, z: dir.z };
1029
1584
  return result;
1030
1585
  }
1031
- const groundGroup = new THREE5__namespace.Group();
1586
+ const groundGroup = new THREE6__namespace.Group();
1032
1587
  scene.add(groundGroup);
1588
+ const MAX_HORIZON_POINTS = 64;
1589
+ let groundMaterial = null;
1590
+ let horizonLine = null;
1591
+ let activeHorizonProfile = {
1592
+ mode: 0,
1593
+ pointCount: 0,
1594
+ azDeg: [],
1595
+ altDeg: [],
1596
+ rotateRad: 0,
1597
+ baseAltDeg: 3
1598
+ };
1599
+ let lastHorizonDiagTs = 0;
1600
+ function toColor(input, fallbackHex) {
1601
+ if (!input) return new THREE6__namespace.Color(fallbackHex);
1602
+ try {
1603
+ return new THREE6__namespace.Color(input);
1604
+ } catch {
1605
+ return new THREE6__namespace.Color(fallbackHex);
1606
+ }
1607
+ }
1608
+ function applyGroundTheme(cfg) {
1609
+ if (!groundMaterial) return;
1610
+ const theme = getSceneDebug()?.disableHorizonTheme ? void 0 : cfg?.horizonTheme;
1611
+ const uniforms = groundMaterial.uniforms;
1612
+ const atmo = theme?.atmosphere;
1613
+ const mode = theme?.source === "polygonal" && (theme.profile?.points?.length ?? 0) >= 2 ? 1 : 0;
1614
+ const groundColor = toColor(theme?.groundColor, 65794);
1615
+ const fogColor = toColor(theme?.horizonLineColor, 663098);
1616
+ const fogIntensity = THREE6__namespace.MathUtils.clamp(atmo?.fogIntensity ?? 0.6, 0, 1.5);
1617
+ const fogVisible = atmo?.fogVisible === false ? 0 : 1;
1618
+ const minBrightness = THREE6__namespace.MathUtils.clamp(atmo?.minimalBrightness ?? 0, 0, 1);
1619
+ const rotateRad = (theme?.profile?.angleRotateZDeg ?? 0) * Math.PI / 180;
1620
+ const azSamples = new Array(MAX_HORIZON_POINTS).fill(0);
1621
+ const altSamples = new Array(MAX_HORIZON_POINTS).fill(0);
1622
+ let pointCount = 0;
1623
+ let sortedPoints = [];
1624
+ if (mode === 1 && theme?.profile?.points) {
1625
+ sortedPoints = [...theme.profile.points].map((p) => ({
1626
+ azDeg: (p.azDeg % 360 + 360) % 360,
1627
+ altDeg: THREE6__namespace.MathUtils.clamp(p.altDeg, -30, 35)
1628
+ })).sort((a, b) => a.azDeg - b.azDeg);
1629
+ pointCount = Math.min(sortedPoints.length, MAX_HORIZON_POINTS);
1630
+ for (let i = 0; i < pointCount; i++) {
1631
+ azSamples[i] = sortedPoints[i].azDeg;
1632
+ altSamples[i] = sortedPoints[i].altDeg;
1633
+ }
1634
+ }
1635
+ const baseAltDeg = pointCount > 0 ? altSamples.slice(0, pointCount).reduce((sum, v) => sum + v, 0) / pointCount : 3;
1636
+ activeHorizonProfile = {
1637
+ mode,
1638
+ pointCount,
1639
+ azDeg: azSamples.slice(0, pointCount),
1640
+ altDeg: altSamples.slice(0, pointCount),
1641
+ rotateRad,
1642
+ baseAltDeg
1643
+ };
1644
+ uniforms.color.value = groundColor;
1645
+ uniforms.fogColor.value = fogColor;
1646
+ uniforms.uFogIntensity.value = fogIntensity;
1647
+ uniforms.uFogVisible.value = fogVisible;
1648
+ uniforms.uMinBrightness.value = minBrightness;
1649
+ uniforms.uHorizonMode.value = mode;
1650
+ uniforms.uHorizonPointCount.value = pointCount;
1651
+ uniforms.uHorizonAzDeg.value = azSamples;
1652
+ uniforms.uHorizonAltDeg.value = altSamples;
1653
+ uniforms.uHorizonRotateRad.value = rotateRad;
1654
+ uniforms.uBaseAltDeg.value = baseAltDeg;
1655
+ groundMaterial.uniformsNeedUpdate = true;
1656
+ if (atmosphereMesh && atmosphereMesh.material instanceof THREE6__namespace.ShaderMaterial) {
1657
+ const atmUniforms = atmosphereMesh.material.uniforms;
1658
+ const topAltDeg = atmo?.fogBandTopAltDeg ?? 90;
1659
+ const bottomAltDeg = atmo?.fogBandBottomAltDeg ?? -90;
1660
+ atmUniforms.uThemeFogVisible.value = fogVisible;
1661
+ atmUniforms.uThemeFogIntensity.value = fogIntensity;
1662
+ atmUniforms.uThemeFogTopSin.value = Math.sin(THREE6__namespace.MathUtils.degToRad(topAltDeg));
1663
+ atmUniforms.uThemeFogBottomSin.value = Math.sin(THREE6__namespace.MathUtils.degToRad(bottomAltDeg));
1664
+ atmUniforms.uThemeMinBrightness.value = minBrightness;
1665
+ atmosphereMesh.material.uniformsNeedUpdate = true;
1666
+ }
1667
+ if (horizonLine) {
1668
+ groundGroup.remove(horizonLine);
1669
+ horizonLine.geometry.dispose();
1670
+ horizonLine.material.dispose();
1671
+ horizonLine = null;
1672
+ }
1673
+ const lineThickness = THREE6__namespace.MathUtils.clamp(theme?.horizonLineThickness ?? 0, 0, 8);
1674
+ const shouldDrawLine = mode === 1 && pointCount >= 2 && lineThickness > 0;
1675
+ if (!shouldDrawLine) return;
1676
+ const lineColor = toColor(theme?.horizonLineColor, 5601177);
1677
+ const lineRadius = 997;
1678
+ const pts = [];
1679
+ for (let i = 0; i < pointCount; i++) {
1680
+ const sample = sortedPoints[i];
1681
+ const angleDeg = sample.azDeg - (theme?.profile?.angleRotateZDeg ?? 0);
1682
+ const a = THREE6__namespace.MathUtils.degToRad(angleDeg);
1683
+ const alt = THREE6__namespace.MathUtils.degToRad(sample.altDeg);
1684
+ const rc = Math.cos(alt);
1685
+ pts.push(new THREE6__namespace.Vector3(
1686
+ lineRadius * rc * Math.cos(a),
1687
+ lineRadius * Math.sin(alt),
1688
+ lineRadius * rc * Math.sin(a)
1689
+ ));
1690
+ }
1691
+ if (pts.length > 0) pts.push(pts[0].clone());
1692
+ const geo = new THREE6__namespace.BufferGeometry().setFromPoints(pts);
1693
+ const mat = createSmartMaterial({
1694
+ uniforms: {
1695
+ color: { value: lineColor },
1696
+ alpha: { value: 0.95 }
1697
+ },
1698
+ vertexShaderBody: `
1699
+ uniform vec3 color;
1700
+ varying vec3 vColor;
1701
+ void main() {
1702
+ vColor = color;
1703
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1704
+ gl_Position = smartProject(mvPosition);
1705
+ vScreenPos = gl_Position.xy / gl_Position.w;
1706
+ }
1707
+ `,
1708
+ fragmentShader: `
1709
+ uniform float alpha;
1710
+ varying vec3 vColor;
1711
+ void main() {
1712
+ float alphaMask = getMaskAlpha();
1713
+ if (alphaMask < 0.01) discard;
1714
+ gl_FragColor = vec4(vColor, alpha * alphaMask);
1715
+ }
1716
+ `,
1717
+ transparent: true,
1718
+ depthWrite: false,
1719
+ depthTest: true
1720
+ });
1721
+ const line = new THREE6__namespace.Line(geo, mat);
1722
+ line.material.linewidth = lineThickness;
1723
+ line.frustumCulled = false;
1724
+ line.renderOrder = 3;
1725
+ horizonLine = line;
1726
+ groundGroup.add(line);
1727
+ }
1728
+ function sampleActiveHorizonAltDeg(azDeg) {
1729
+ const profile = activeHorizonProfile;
1730
+ if (profile.mode !== 1 || profile.pointCount < 2) return profile.baseAltDeg;
1731
+ const query = ((azDeg + THREE6__namespace.MathUtils.radToDeg(profile.rotateRad)) % 360 + 360) % 360;
1732
+ const n = profile.pointCount;
1733
+ const firstAz = profile.azDeg[0];
1734
+ const firstAlt = profile.altDeg[0];
1735
+ for (let i = 1; i < n; i++) {
1736
+ const prevAz2 = profile.azDeg[i - 1];
1737
+ const prevAlt2 = profile.altDeg[i - 1];
1738
+ const curAz = profile.azDeg[i];
1739
+ const curAlt = profile.altDeg[i];
1740
+ if (query >= prevAz2 && query <= curAz) {
1741
+ const t2 = (query - prevAz2) / Math.max(1e-4, curAz - prevAz2);
1742
+ return mix(prevAlt2, curAlt, t2);
1743
+ }
1744
+ }
1745
+ const prevAz = profile.azDeg[n - 1];
1746
+ const prevAlt = profile.altDeg[n - 1];
1747
+ const wrappedQuery = query < firstAz ? query + 360 : query;
1748
+ const t = (wrappedQuery - prevAz) / Math.max(1e-4, firstAz + 360 - prevAz);
1749
+ return mix(prevAlt, firstAlt, t);
1750
+ }
1751
+ function runHorizonDiagnostics(nowMs) {
1752
+ if (nowMs - lastHorizonDiagTs < 1200) return;
1753
+ lastHorizonDiagTs = nowMs;
1754
+ const points = [];
1755
+ const r = 997;
1756
+ const scale = globalUniforms.uScale.value;
1757
+ const aspect = Math.max(1e-4, globalUniforms.uAspect.value);
1758
+ for (let az = 0; az < 360; az += 2) {
1759
+ const altDeg = sampleActiveHorizonAltDeg(az);
1760
+ const azRad = THREE6__namespace.MathUtils.degToRad(az);
1761
+ const altRad = THREE6__namespace.MathUtils.degToRad(altDeg);
1762
+ const rc = Math.cos(altRad);
1763
+ const worldPos = new THREE6__namespace.Vector3(
1764
+ r * rc * Math.cos(azRad),
1765
+ r * Math.sin(altRad),
1766
+ r * rc * Math.sin(azRad)
1767
+ );
1768
+ const p = smartProjectJS(worldPos);
1769
+ if (currentProjection.isClipped(p.z)) continue;
1770
+ const x = p.x * scale / aspect;
1771
+ const y = p.y * scale;
1772
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
1773
+ if (Math.abs(x) > 1) continue;
1774
+ points.push({ x, y });
1775
+ }
1776
+ if (points.length < 16) {
1777
+ console.debug(`[HorizonDiag] insufficient visible horizon samples at fov=${state.fov.toFixed(1)}`);
1778
+ return;
1779
+ }
1780
+ const binCount = 12;
1781
+ const maxY = new Array(binCount).fill(-Infinity);
1782
+ for (const p of points) {
1783
+ const ax = Math.min(0.999, Math.abs(p.x));
1784
+ const idx = Math.floor(ax * binCount);
1785
+ maxY[idx] = Math.max(maxY[idx], p.y);
1786
+ }
1787
+ const compact = maxY.map((v) => Number.isFinite(v) ? Number(v.toFixed(3)) : null);
1788
+ let dropCount = 0;
1789
+ for (let i = 1; i < binCount; i++) {
1790
+ const prev = maxY[i - 1];
1791
+ const cur = maxY[i];
1792
+ if (!Number.isFinite(prev) || !Number.isFinite(cur)) continue;
1793
+ if (cur < prev - 0.02) dropCount++;
1794
+ }
1795
+ const flatten = groundMaterial?.uniforms?.uZenithFlatten?.value;
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);
1802
+ console.debug(
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)}`
1804
+ );
1805
+ }
1033
1806
  function createGround() {
1034
1807
  groundGroup.clear();
1035
1808
  const radius = 995;
1036
- const geometry = new THREE5__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
1809
+ const geometry = new THREE6__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
1037
1810
  const material = createSmartMaterial({
1038
1811
  uniforms: {
1039
- color: { value: new THREE5__namespace.Color(65794) },
1040
- fogColor: { value: new THREE5__namespace.Color(663098) }
1812
+ color: { value: new THREE6__namespace.Color(65794) },
1813
+ fogColor: { value: new THREE6__namespace.Color(663098) },
1814
+ uFogIntensity: { value: 0.6 },
1815
+ uFogVisible: { value: 1 },
1816
+ uMinBrightness: { value: 0 },
1817
+ uHorizonMode: { value: 0 },
1818
+ uHorizonPointCount: { value: 0 },
1819
+ uHorizonAzDeg: { value: new Array(MAX_HORIZON_POINTS).fill(0) },
1820
+ uHorizonAltDeg: { value: new Array(MAX_HORIZON_POINTS).fill(0) },
1821
+ uHorizonRotateRad: { value: 0 },
1822
+ uHorizonRadius: { value: radius },
1823
+ uBaseAltDeg: { value: 3 },
1824
+ uZenithFlatten: { value: 0 }
1041
1825
  },
1042
1826
  vertexShaderBody: `
1043
- varying vec3 vPos;
1044
- varying vec3 vWorldPos;
1045
- void main() {
1046
- vPos = position;
1047
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1048
- gl_Position = smartProject(mvPosition);
1049
- vScreenPos = gl_Position.xy / gl_Position.w;
1050
- vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
1827
+ varying vec3 vPos;
1828
+ varying vec3 vWorldPos;
1829
+ varying float vViewDirZ;
1830
+ void main() {
1831
+ vPos = position;
1832
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1833
+ gl_Position = smartProject(mvPosition);
1834
+ vScreenPos = gl_Position.xy / gl_Position.w;
1835
+ vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
1836
+ vViewDirZ = normalize(mvPosition.xyz).z;
1837
+ }
1838
+ `,
1839
+ fragmentShader: `
1840
+ uniform vec3 color;
1841
+ uniform vec3 fogColor;
1842
+ uniform float uFogIntensity;
1843
+ uniform float uFogVisible;
1844
+ uniform float uMinBrightness;
1845
+ uniform int uHorizonMode;
1846
+ uniform int uHorizonPointCount;
1847
+ uniform float uHorizonAzDeg[64];
1848
+ uniform float uHorizonAltDeg[64];
1849
+ uniform float uHorizonRotateRad;
1850
+ uniform float uHorizonRadius;
1851
+ uniform float uBaseAltDeg;
1852
+ uniform float uZenithFlatten;
1853
+ varying vec3 vPos;
1854
+ varying vec3 vWorldPos;
1855
+ varying float vViewDirZ;
1856
+
1857
+ float samplePolygonalAltDeg(float azDeg) {
1858
+ if (uHorizonPointCount < 2) return 0.0;
1859
+ float z = mod(azDeg, 360.0);
1860
+ if (z < 0.0) z += 360.0;
1861
+
1862
+ float prevAz = uHorizonAzDeg[0];
1863
+ float prevAlt = uHorizonAltDeg[0];
1864
+ for (int i = 1; i < 64; i++) {
1865
+ if (i >= uHorizonPointCount) break;
1866
+ float curAz = uHorizonAzDeg[i];
1867
+ float curAlt = uHorizonAltDeg[i];
1868
+ if (z >= prevAz && z <= curAz) {
1869
+ float t = (z - prevAz) / max(0.0001, curAz - prevAz);
1870
+ return mix(prevAlt, curAlt, t);
1871
+ }
1872
+ prevAz = curAz;
1873
+ prevAlt = curAlt;
1874
+ }
1875
+
1876
+ float firstAz = uHorizonAzDeg[0] + 360.0;
1877
+ float firstAlt = uHorizonAltDeg[0];
1878
+ float zw = z;
1879
+ if (zw < uHorizonAzDeg[0]) zw += 360.0;
1880
+ float t = (zw - prevAz) / max(0.0001, firstAz - prevAz);
1881
+ return mix(prevAlt, firstAlt, t);
1882
+ }
1883
+
1884
+ void main() {
1885
+ float alphaMask = getMaskAlpha();
1886
+ if (alphaMask < 0.01) discard;
1887
+
1888
+ // Keep ground visibility aligned with the active projection clip.
1889
+ float clipZ = -0.1;
1890
+ if (uProjectionType == 1) {
1891
+ clipZ = 0.1;
1892
+ } else if (uProjectionType == 2) {
1893
+ clipZ = mix(-0.1, 0.1, clamp(uBlend, 0.0, 1.0));
1894
+ }
1895
+ if (vViewDirZ > clipZ) discard;
1896
+
1897
+ float angle = atan(vPos.z, vPos.x);
1898
+ float terrainHeight;
1899
+
1900
+ if (uHorizonMode == 1 && uHorizonPointCount >= 2) {
1901
+ float azDeg = mod(degrees(angle) + 360.0 + degrees(uHorizonRotateRad), 360.0);
1902
+ float altDeg = samplePolygonalAltDeg(azDeg);
1903
+ terrainHeight = uHorizonRadius * sin(radians(altDeg));
1904
+ } else {
1905
+ // Procedural Horizon (Mountains)
1906
+ float h = 0.0;
1907
+ h += sin(angle * 6.0) * 35.0;
1908
+ h += sin(angle * 13.0 + 1.0) * 18.0;
1909
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1910
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1911
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1912
+ terrainHeight = h + 12.0;
1913
+ }
1914
+ float circularHeight = uHorizonRadius * sin(radians(uBaseAltDeg));
1915
+ terrainHeight = mix(terrainHeight, circularHeight, clamp(uZenithFlatten, 0.0, 1.0));
1916
+
1917
+ if (vPos.y > terrainHeight) discard;
1918
+
1919
+ // Atmospheric rim glow just below terrain peaks
1920
+ float rimDist = terrainHeight - vPos.y;
1921
+ float rim = exp(-rimDist * 0.15) * 0.4 * uFogVisible;
1922
+ vec3 rimColor = fogColor * 1.5;
1923
+
1924
+ // Atmospheric haze \u2014 stronger near horizon
1925
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1926
+ vec3 finalCol = mix(color, fogColor, fogFactor * uFogIntensity * uFogVisible);
1927
+
1928
+ // Add rim glow near terrain peaks
1929
+ finalCol += rimColor * rim;
1930
+ finalCol = max(finalCol, color * uMinBrightness);
1931
+
1932
+ gl_FragColor = vec4(finalCol, 1.0);
1933
+ }
1934
+ `,
1935
+ side: THREE6__namespace.BackSide,
1936
+ transparent: false,
1937
+ depthWrite: true,
1938
+ depthTest: true
1939
+ });
1940
+ groundMaterial = material;
1941
+ const ground = new THREE6__namespace.Mesh(geometry, material);
1942
+ groundGroup.add(ground);
1943
+ applyGroundTheme(currentConfig);
1944
+ }
1945
+ let skyBackgroundMesh = null;
1946
+ let atmosphereMesh = null;
1947
+ let moonMesh = null;
1948
+ let moonGlowMesh = null;
1949
+ let sunDiscMesh = null;
1950
+ let sunHaloMesh = null;
1951
+ let milkyWayMesh = null;
1952
+ function createSkyBackground() {
1953
+ const geo = new THREE6__namespace.SphereGeometry(2400, 32, 32);
1954
+ const mat = createSmartMaterial({
1955
+ uniforms: {},
1956
+ vertexShaderBody: `
1957
+ varying vec3 vWorldNormal;
1958
+ void main() {
1959
+ vWorldNormal = normalize(position);
1960
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
1961
+ gl_Position = smartProject(mv);
1962
+ vScreenPos = gl_Position.xy / gl_Position.w;
1963
+ }
1964
+ `,
1965
+ fragmentShader: `
1966
+ varying vec3 vWorldNormal;
1967
+ void main() {
1968
+ float h = clamp(normalize(vWorldNormal).y, -1.0, 1.0);
1969
+
1970
+ // Scotopic-inspired 5-stop gradient.
1971
+ // Night sky: blue channel ~2.6x red, derived from CIE (x=0.25, y=0.25).
1972
+ vec3 cZenith = vec3(0.010, 0.022, 0.055);
1973
+ vec3 cUpper = vec3(0.015, 0.033, 0.080);
1974
+ vec3 cMid = vec3(0.022, 0.048, 0.108);
1975
+ vec3 cLower = vec3(0.035, 0.072, 0.148);
1976
+ vec3 cHorizon = vec3(0.052, 0.100, 0.190);
1977
+
1978
+ float t1 = smoothstep(0.0, 0.30, h);
1979
+ float t2 = smoothstep(0.3, 0.60, h);
1980
+ float t3 = smoothstep(0.6, 0.85, h);
1981
+ float t4 = smoothstep(0.85, 1.00, h);
1982
+
1983
+ vec3 col = cHorizon;
1984
+ col = mix(col, cLower, t1);
1985
+ col = mix(col, cMid, t2);
1986
+ col = mix(col, cUpper, t3);
1987
+ col = mix(col, cZenith, t4);
1988
+
1989
+ // Rayleigh limb brightening at horizon
1990
+ float limb = exp(-18.0 * abs(h)) * smoothstep(-0.05, 0.06, h);
1991
+ col += vec3(0.012, 0.024, 0.050) * limb;
1992
+
1993
+ // Below ground: fade to near-black
1994
+ float below = smoothstep(-0.04, -0.18, h);
1995
+ col = mix(col, vec3(0.002, 0.003, 0.006), below);
1996
+
1997
+ gl_FragColor = vec4(col, 1.0);
1998
+ }
1999
+ `,
2000
+ transparent: false,
2001
+ depthWrite: false,
2002
+ depthTest: false,
2003
+ side: THREE6__namespace.BackSide
2004
+ });
2005
+ skyBackgroundMesh = new THREE6__namespace.Mesh(geo, mat);
2006
+ skyBackgroundMesh.renderOrder = -2;
2007
+ skyBackgroundMesh.frustumCulled = false;
2008
+ scene.add(skyBackgroundMesh);
2009
+ }
2010
+ function createAtmosphere() {
2011
+ const geometry = new THREE6__namespace.SphereGeometry(990, 64, 64);
2012
+ const material = createSmartMaterial({
2013
+ uniforms: {
2014
+ uThemeFogVisible: { value: 1 },
2015
+ uThemeFogTopSin: { value: 0.95 },
2016
+ uThemeFogBottomSin: { value: -1 },
2017
+ uThemeFogIntensity: { value: 1 },
2018
+ uThemeMinBrightness: { value: 0 }
2019
+ },
2020
+ vertexShaderBody: `
2021
+ varying vec3 vWorldNormal;
2022
+ void main() {
2023
+ vWorldNormal = normalize(position);
2024
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
2025
+ gl_Position = smartProject(mv);
2026
+ vScreenPos = gl_Position.xy / gl_Position.w;
2027
+ }`,
2028
+ fragmentShader: `
2029
+ varying vec3 vWorldNormal;
2030
+
2031
+ uniform float uAtmGlow;
2032
+ uniform float uAtmDark;
2033
+ uniform vec3 uColorHorizon;
2034
+ uniform vec3 uColorZenith;
2035
+ uniform float uThemeFogVisible;
2036
+ uniform float uThemeFogTopSin;
2037
+ uniform float uThemeFogBottomSin;
2038
+ uniform float uThemeFogIntensity;
2039
+ uniform float uThemeMinBrightness;
2040
+
2041
+ void main() {
2042
+ float alphaMask = getMaskAlpha();
2043
+ if (alphaMask < 0.01) discard;
2044
+
2045
+ // Altitude angle (Y is up)
2046
+ float h = normalize(vWorldNormal).y;
2047
+
2048
+ // 1. Base gradient from Horizon to Zenith (wider range)
2049
+ float t = smoothstep(-0.15, 0.7, h);
2050
+
2051
+ // Non-linear mix for realistic sky falloff
2052
+ vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
2053
+ float hazeBand = smoothstep(uThemeFogBottomSin, uThemeFogTopSin, h);
2054
+ float hazeFadeEnd = max(uThemeFogTopSin + 0.001, min(1.0, uThemeFogTopSin + 0.25));
2055
+ hazeBand *= (1.0 - smoothstep(uThemeFogTopSin, hazeFadeEnd, h));
2056
+ float fogTheme = uThemeFogVisible * uThemeFogIntensity;
2057
+
2058
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
2059
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
2060
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
2061
+
2062
+ // 3. Primary horizon glow band (wider than before)
2063
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
2064
+ skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow * fogTheme * max(0.15, hazeBand);
2065
+
2066
+ // 4. Warm secondary glow (light pollution / sodium scatter)
2067
+ float warmGlow = exp(-8.0 * abs(h));
2068
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow * fogTheme * max(0.15, hazeBand);
2069
+ skyColor = max(skyColor, uColorZenith * (0.2 * uThemeMinBrightness));
2070
+
2071
+ gl_FragColor = vec4(skyColor, 1.0);
2072
+ }
2073
+ `,
2074
+ side: THREE6__namespace.BackSide,
2075
+ depthWrite: false,
2076
+ depthTest: true
2077
+ });
2078
+ const atm = new THREE6__namespace.Mesh(geometry, material);
2079
+ atmosphereMesh = atm;
2080
+ groundGroup.add(atm);
2081
+ }
2082
+ function createMoon() {
2083
+ const moonDir = new THREE6__namespace.Vector3(-0.38, 0.62, -0.68).normalize();
2084
+ const moonWorldPos = moonDir.clone().multiplyScalar(2e3);
2085
+ const glowGeo = new THREE6__namespace.PlaneGeometry(1, 1);
2086
+ const glowMat = createSmartMaterial({
2087
+ uniforms: { uMoonSize: { value: 0.082 } },
2088
+ vertexShaderBody: `
2089
+ uniform float uMoonSize;
2090
+ varying vec2 vUv;
2091
+ void main() {
2092
+ vUv = uv;
2093
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2094
+ vec4 projected = smartProject(mvPos);
2095
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2096
+ vec2 offset = position.xy * uMoonSize * uScale * 2.4;
2097
+ projected.xy += offset / vec2(uAspect, 1.0);
2098
+ vScreenPos = projected.xy / projected.w;
2099
+ gl_Position = projected;
2100
+ }
2101
+ `,
2102
+ fragmentShader: `
2103
+ varying vec2 vUv;
2104
+ void main() {
2105
+ float alphaMask = getMaskAlpha();
2106
+ if (alphaMask < 0.01) discard;
2107
+ vec2 p = vUv * 2.0 - 1.0;
2108
+ float d = length(p);
2109
+ if (d > 1.0) discard;
2110
+ float halo = exp(-5.0 * d * d) * 0.07;
2111
+ halo += exp(-2.5 * max(0.0, d - 0.42)) * 0.045;
2112
+ if (halo < 0.003) discard;
2113
+ gl_FragColor = vec4(vec3(0.78, 0.88, 1.0) * halo, halo * alphaMask);
2114
+ }
2115
+ `,
2116
+ transparent: true,
2117
+ depthWrite: false,
2118
+ depthTest: true,
2119
+ blending: THREE6__namespace.AdditiveBlending
2120
+ });
2121
+ moonGlowMesh = new THREE6__namespace.Mesh(glowGeo, glowMat);
2122
+ moonGlowMesh.position.copy(moonWorldPos);
2123
+ moonGlowMesh.frustumCulled = false;
2124
+ moonGlowMesh.renderOrder = 2;
2125
+ scene.add(moonGlowMesh);
2126
+ const discGeo = new THREE6__namespace.PlaneGeometry(1, 1);
2127
+ const discMat = createSmartMaterial({
2128
+ uniforms: { uMoonSize: { value: 0.082 } },
2129
+ vertexShaderBody: `
2130
+ uniform float uMoonSize;
2131
+ varying vec2 vUv;
2132
+ void main() {
2133
+ vUv = uv;
2134
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2135
+ vec4 projected = smartProject(mvPos);
2136
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2137
+ vec2 offset = position.xy * uMoonSize * uScale;
2138
+ projected.xy += offset / vec2(uAspect, 1.0);
2139
+ vScreenPos = projected.xy / projected.w;
2140
+ gl_Position = projected;
2141
+ }
2142
+ `,
2143
+ fragmentShader: `
2144
+ varying vec2 vUv;
2145
+ void main() {
2146
+ float alphaMask = getMaskAlpha();
2147
+ if (alphaMask < 0.01) discard;
2148
+ vec2 p = vUv * 2.0 - 1.0;
2149
+ float d = length(p);
2150
+ if (d > 1.0) discard;
2151
+
2152
+ float edge = smoothstep(1.0, 0.90, d);
2153
+
2154
+ // Phase: sunlight from upper-right (gibbous moon)
2155
+ vec2 sunDir2D = normalize(vec2(0.55, 0.45));
2156
+ float phaseRaw = dot(normalize(p + vec2(0.0001)), sunDir2D);
2157
+ float lit = smoothstep(-0.18, 0.32, phaseRaw);
2158
+
2159
+ // Limb darkening (classical sqrt law)
2160
+ float cosTheta = sqrt(max(0.001, 1.0 - d * d));
2161
+ float limb = cosTheta * 0.42 + 0.58;
2162
+
2163
+ // Procedural surface texture
2164
+ float angle = atan(p.y, p.x);
2165
+ float r = d;
2166
+ float detail = sin(angle * 5.0 + 2.1) * sin(r * 8.3) * 0.038
2167
+ + sin(angle * 11.0 - 1.3) * sin(r * 13.0) * 0.022
2168
+ + sin(angle * 2.0 + 0.8) * (1.0 - r) * 0.055
2169
+ + sin(angle * 17.0 + r * 6.5) * 0.014
2170
+ + sin(angle * 23.0 - r * 11.0) * 0.009;
2171
+
2172
+ // Mare (dark maria) patches
2173
+ float mare1 = 1.0 - smoothstep(0.0, 0.30, length(p - vec2(-0.20, 0.22)));
2174
+ float mare2 = 1.0 - smoothstep(0.0, 0.20, length(p - vec2( 0.10, 0.30)));
2175
+ float mare3 = 1.0 - smoothstep(0.0, 0.24, length(p - vec2( 0.17,-0.06)));
2176
+ float mare4 = 1.0 - smoothstep(0.0, 0.14, length(p - vec2(-0.30,-0.20)));
2177
+ float totalMare = clamp(mare1*0.50 + mare2*0.38 + mare3*0.32 + mare4*0.28, 0.0, 0.58);
2178
+
2179
+ vec3 highland = vec3(0.88, 0.85, 0.80);
2180
+ vec3 mareColor = vec3(0.40, 0.39, 0.37);
2181
+ vec3 moonBase = clamp(mix(highland, mareColor, totalMare) + detail, 0.0, 1.0);
2182
+
2183
+ vec3 litSurface = moonBase * limb;
2184
+ vec3 earthshine = vec3(0.038, 0.052, 0.078);
2185
+ vec3 finalColor = mix(earthshine, litSurface, lit);
2186
+
2187
+ gl_FragColor = vec4(finalColor * edge, edge * alphaMask);
2188
+ }
2189
+ `,
2190
+ transparent: true,
2191
+ depthWrite: true,
2192
+ depthTest: true,
2193
+ blending: THREE6__namespace.NormalBlending
2194
+ });
2195
+ moonMesh = new THREE6__namespace.Mesh(discGeo, discMat);
2196
+ moonMesh.position.copy(moonWorldPos);
2197
+ moonMesh.frustumCulled = false;
2198
+ moonMesh.renderOrder = 3;
2199
+ scene.add(moonMesh);
2200
+ }
2201
+ function createSun() {
2202
+ const sunDir = new THREE6__namespace.Vector3(-1, -0.08, 0).normalize();
2203
+ const sunWorldPos = sunDir.clone().multiplyScalar(2e3);
2204
+ const haloGeo = new THREE6__namespace.PlaneGeometry(1, 1);
2205
+ const haloMat = createSmartMaterial({
2206
+ uniforms: { uSunHaloSize: { value: 0.46 } },
2207
+ vertexShaderBody: `
2208
+ uniform float uSunHaloSize;
2209
+ varying vec2 vUv;
2210
+ void main() {
2211
+ vUv = uv;
2212
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2213
+ vec4 projected = smartProject(mvPos);
2214
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2215
+ vec2 offset = position.xy * uSunHaloSize * uScale;
2216
+ projected.xy += offset / vec2(uAspect, 1.0);
2217
+ vScreenPos = projected.xy / projected.w;
2218
+ gl_Position = projected;
2219
+ }
2220
+ `,
2221
+ fragmentShader: `
2222
+ varying vec2 vUv;
2223
+ void main() {
2224
+ float alphaMask = getMaskAlpha();
2225
+ if (alphaMask < 0.01) discard;
2226
+
2227
+ vec2 p = vUv * 2.0 - 1.0;
2228
+ float d = length(p);
2229
+ if (d > 1.0) discard;
2230
+
2231
+ // Asymmetric falloff: spread wider horizontally than vertically
2232
+ float asymDist = length(vec2(p.x * 0.55, p.y));
2233
+
2234
+ // Radial glow: warm near centre, fading outward
2235
+ float glow = exp(-2.8 * asymDist * asymDist) * 1.0;
2236
+ glow += exp(-1.0 * asymDist) * 0.35;
2237
+
2238
+ // Crepuscular rays: fan out from bottom, visible above sun centre
2239
+ float rayMask = smoothstep(-0.05, 0.35, p.y);
2240
+ float rayFade = max(0.0, 1.0 - d) * (1.0 - d);
2241
+ float rayAngle = atan(p.x, max(0.0001, p.y)); // angle from vertical
2242
+ float rays = pow(abs(sin(rayAngle * 7.0 + 0.30)), 9.0) * 0.10
2243
+ + pow(abs(sin(rayAngle * 13.0 - 1.10)), 14.0) * 0.07
2244
+ + pow(abs(sin(rayAngle * 19.0 + 2.30)), 11.0) * 0.05;
2245
+ rays *= rayMask * rayFade;
2246
+
2247
+ // Colour: white-yellow \u2192 orange \u2192 hot-pink \u2192 purple
2248
+ vec3 cYellow = vec3(1.00, 0.88, 0.52);
2249
+ vec3 cOrange = vec3(1.00, 0.42, 0.10);
2250
+ vec3 cPink = vec3(0.90, 0.22, 0.52);
2251
+ vec3 cPurple = vec3(0.38, 0.12, 0.48);
2252
+ vec3 col = mix(cYellow, cOrange, smoothstep(0.00, 0.40, asymDist));
2253
+ col = mix(col, cPink, smoothstep(0.35, 0.72, asymDist));
2254
+ col = mix(col, cPurple, smoothstep(0.65, 1.00, asymDist));
2255
+
2256
+ float total = (glow + rays) * alphaMask;
2257
+ if (total < 0.005) discard;
2258
+ gl_FragColor = vec4(col * total, total);
2259
+ }
2260
+ `,
2261
+ transparent: true,
2262
+ depthWrite: false,
2263
+ depthTest: true,
2264
+ blending: THREE6__namespace.AdditiveBlending
2265
+ });
2266
+ sunHaloMesh = new THREE6__namespace.Mesh(haloGeo, haloMat);
2267
+ sunHaloMesh.position.copy(sunWorldPos);
2268
+ sunHaloMesh.frustumCulled = false;
2269
+ sunHaloMesh.renderOrder = 1;
2270
+ scene.add(sunHaloMesh);
2271
+ const discGeo = new THREE6__namespace.PlaneGeometry(1, 1);
2272
+ const discMat = createSmartMaterial({
2273
+ uniforms: { uSunSize: { value: 0.09 } },
2274
+ vertexShaderBody: `
2275
+ uniform float uSunSize;
2276
+ varying vec2 vUv;
2277
+ void main() {
2278
+ vUv = uv;
2279
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2280
+ vec4 projected = smartProject(mvPos);
2281
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2282
+ vec2 offset = position.xy * uSunSize * uScale;
2283
+ projected.xy += offset / vec2(uAspect, 1.0);
2284
+ vScreenPos = projected.xy / projected.w;
2285
+ gl_Position = projected;
1051
2286
  }
1052
2287
  `,
1053
2288
  fragmentShader: `
1054
- uniform vec3 color;
1055
- uniform vec3 fogColor;
1056
- varying vec3 vPos;
1057
- varying vec3 vWorldPos;
1058
-
1059
- void main() {
1060
- float alphaMask = getMaskAlpha();
1061
- if (alphaMask < 0.01) discard;
1062
-
1063
- // Procedural Horizon (Mountains)
1064
- float angle = atan(vPos.z, vPos.x);
1065
-
1066
- // FBM-like terrain with increased amplitude
1067
- float h = 0.0;
1068
- h += sin(angle * 6.0) * 35.0;
1069
- h += sin(angle * 13.0 + 1.0) * 18.0;
1070
- h += sin(angle * 29.0 + 2.0) * 8.0;
1071
- h += sin(angle * 63.0 + 4.0) * 3.0;
1072
- h += sin(angle * 97.0 + 5.0) * 1.5;
2289
+ varying vec2 vUv;
2290
+ void main() {
2291
+ float alphaMask = getMaskAlpha();
2292
+ if (alphaMask < 0.01) discard;
1073
2293
 
1074
- float terrainHeight = h + 12.0;
2294
+ vec2 p = vUv * 2.0 - 1.0;
2295
+ float d = length(p);
2296
+ if (d > 1.0) discard;
1075
2297
 
1076
- if (vPos.y > terrainHeight) discard;
2298
+ float edge = smoothstep(1.0, 0.86, d);
1077
2299
 
1078
- // Atmospheric rim glow just below terrain peaks
1079
- float rimDist = terrainHeight - vPos.y;
1080
- float rim = exp(-rimDist * 0.15) * 0.4;
1081
- vec3 rimColor = fogColor * 1.5;
2300
+ // Photosphere limb darkening: bright white core \u2192 orange limb
2301
+ float core = smoothstep(0.28, 0.00, d);
2302
+ float mid = smoothstep(0.68, 0.22, d) * (1.0 - core);
2303
+ float limb = (1.0 - smoothstep(0.70, 1.00, d)) * (1.0 - core - mid);
1082
2304
 
1083
- // Atmospheric haze \u2014 stronger near horizon
1084
- float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1085
- vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
2305
+ vec3 cCore = vec3(1.00, 0.97, 0.88); // hot white
2306
+ vec3 cMid = vec3(1.00, 0.80, 0.38); // yellow
2307
+ vec3 cLimb = vec3(1.00, 0.52, 0.08); // deep orange
1086
2308
 
1087
- // Add rim glow near terrain peaks
1088
- finalCol += rimColor * rim;
2309
+ vec3 col = cCore * (core + 0.12) + cMid * mid + cLimb * limb;
2310
+ col = clamp(col, 0.0, 1.5); // allow slight overbright
1089
2311
 
1090
- gl_FragColor = vec4(finalCol, 1.0);
2312
+ gl_FragColor = vec4(col * edge, edge * alphaMask);
1091
2313
  }
1092
2314
  `,
1093
- side: THREE5__namespace.BackSide,
1094
- transparent: false,
2315
+ transparent: true,
1095
2316
  depthWrite: true,
1096
- depthTest: true
2317
+ depthTest: true,
2318
+ blending: THREE6__namespace.NormalBlending
1097
2319
  });
1098
- const ground = new THREE5__namespace.Mesh(geometry, material);
1099
- groundGroup.add(ground);
2320
+ sunDiscMesh = new THREE6__namespace.Mesh(discGeo, discMat);
2321
+ sunDiscMesh.position.copy(sunWorldPos);
2322
+ sunDiscMesh.frustumCulled = false;
2323
+ sunDiscMesh.renderOrder = 3;
2324
+ scene.add(sunDiscMesh);
1100
2325
  }
1101
- let atmosphereMesh = null;
1102
- function createAtmosphere() {
1103
- const geometry = new THREE5__namespace.SphereGeometry(990, 64, 64);
1104
- const material = createSmartMaterial({
2326
+ function createMilkyWay() {
2327
+ if (milkyWayMesh) {
2328
+ scene.remove(milkyWayMesh);
2329
+ milkyWayMesh.geometry.dispose();
2330
+ milkyWayMesh.material.dispose();
2331
+ milkyWayMesh = null;
2332
+ }
2333
+ const geo = new THREE6__namespace.PlaneGeometry(1100, 380, 4, 4);
2334
+ const mat = createSmartMaterial({
2335
+ uniforms: {},
1105
2336
  vertexShaderBody: `
1106
- varying vec3 vWorldNormal;
1107
- void main() {
1108
- vWorldNormal = normalize(position);
1109
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
1110
- gl_Position = smartProject(mv);
2337
+ varying vec2 vUv;
2338
+ void main() {
2339
+ vUv = uv;
2340
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
2341
+ gl_Position = smartProject(mv);
1111
2342
  vScreenPos = gl_Position.xy / gl_Position.w;
1112
- }`,
2343
+ }
2344
+ `,
1113
2345
  fragmentShader: `
1114
- varying vec3 vWorldNormal;
1115
-
1116
- uniform float uAtmGlow;
1117
- uniform float uAtmDark;
1118
- uniform vec3 uColorHorizon;
1119
- uniform vec3 uColorZenith;
1120
-
2346
+ varying vec2 vUv;
2347
+
2348
+ // --- Noise helpers ---
2349
+ float hash(vec2 p) {
2350
+ p = fract(p * vec2(127.1, 311.7));
2351
+ p += dot(p, p + 19.19);
2352
+ return fract(p.x * p.y);
2353
+ }
2354
+ float vnoise(vec2 p) {
2355
+ vec2 i = floor(p); vec2 f = fract(p);
2356
+ f = f * f * (3.0 - 2.0 * f);
2357
+ return mix(
2358
+ mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
2359
+ mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0,1.0)), f.x), f.y
2360
+ );
2361
+ }
2362
+ float fbm(vec2 p) {
2363
+ float v = 0.0; float a = 0.5;
2364
+ mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
2365
+ for (int i = 0; i < 7; i++) { v += a * vnoise(p); p = m * p; a *= 0.5; }
2366
+ return v;
2367
+ }
2368
+
1121
2369
  void main() {
1122
2370
  float alphaMask = getMaskAlpha();
1123
2371
  if (alphaMask < 0.01) discard;
1124
2372
 
1125
- // Altitude angle (Y is up)
1126
- float h = normalize(vWorldNormal).y;
2373
+ vec2 uv = vUv * 2.0 - 1.0; // -1..1 centred
1127
2374
 
1128
- // 1. Base gradient from Horizon to Zenith (wider range)
1129
- float t = smoothstep(-0.15, 0.7, h);
2375
+ // Galactic band: tight Gaussian falloff vertically
2376
+ float bandMask = exp(-uv.y * uv.y * 10.0);
1130
2377
 
1131
- // Non-linear mix for realistic sky falloff
1132
- vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
2378
+ // Warp UV for organic turbulence (two layers of distortion)
2379
+ vec2 q = vec2(fbm(uv * 1.5),
2380
+ fbm(uv * 1.5 + vec2(5.2, 1.3)));
2381
+ vec2 r = vec2(fbm(uv * 1.0 + 4.0 * q + vec2(1.7, 9.2)),
2382
+ fbm(uv * 1.0 + 4.0 * q + vec2(8.3, 2.8)));
1133
2383
 
1134
- // 2. Teal tint at mid-altitudes (subtle colour variation)
1135
- float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1136
- skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
2384
+ float nebula = fbm(uv * 2.0 + 2.0 * r);
2385
+ float detail = fbm(uv * 5.0 + r * 3.0 + vec2(3.1, 2.7));
2386
+ float fine = fbm(uv * 10.0 + vec2(1.0, 5.0));
1137
2387
 
1138
- // 3. Primary horizon glow band (wider than before)
1139
- float horizonBand = exp(-10.0 * abs(h - 0.02));
1140
- skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
2388
+ // Base density
2389
+ float density = smoothstep(0.30, 0.80, nebula) * bandMask;
2390
+ density += smoothstep(0.45, 0.85, detail) * bandMask * 0.35;
1141
2391
 
1142
- // 4. Warm secondary glow (light pollution / sodium scatter)
1143
- float warmGlow = exp(-8.0 * abs(h));
1144
- skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
2392
+ // Dust lanes \u2014 dark patches carved into the band
2393
+ float dust = fbm(uv * 3.5 + vec2(11.0, 7.0));
2394
+ density *= (1.0 - smoothstep(0.52, 0.62, dust) * 0.7 * bandMask);
1145
2395
 
1146
- gl_FragColor = vec4(skyColor, 1.0);
2396
+ // Galactic core boost toward horizontal centre
2397
+ float galCore = exp(-uv.x * uv.x * 1.2) * bandMask;
2398
+
2399
+ // --- Color palette ---
2400
+ vec3 deepBlue = vec3(0.10, 0.15, 0.45);
2401
+ vec3 midBlue = vec3(0.25, 0.30, 0.65);
2402
+ vec3 purple = vec3(0.40, 0.20, 0.60);
2403
+ vec3 coreWarm = vec3(0.85, 0.80, 0.65); // warm star-cluster glow
2404
+ vec3 pinkNeb = vec3(0.65, 0.28, 0.50); // emission nebula pink
2405
+
2406
+ float t1 = smoothstep(0.3, 0.7, nebula);
2407
+ float t2 = smoothstep(0.5, 0.8, detail);
2408
+ float t3 = smoothstep(0.55, 0.75, fine);
2409
+
2410
+ vec3 color = mix(deepBlue, midBlue, t1);
2411
+ color = mix(color, purple, t2 * 0.5);
2412
+ color = mix(color, pinkNeb, t3 * 0.25 * bandMask);
2413
+ color += coreWarm * galCore * 0.45 * density;
2414
+
2415
+ // Micro-star field \u2014 denser in the band
2416
+ float starThresh = mix(0.975, 0.940, bandMask);
2417
+ float starSeed = hash(floor(vUv * 500.0));
2418
+ float star = step(starThresh, starSeed);
2419
+ float starBright = hash(floor(vUv * 500.0) + 37.0);
2420
+ color += vec3(0.90, 0.95, 1.0) * star * (0.4 + 0.6 * starBright);
2421
+ density = max(density, star * bandMask * 0.5);
2422
+
2423
+ // Soft edge vignette
2424
+ float ex = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
2425
+ float ey = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.82, vUv.y);
2426
+
2427
+ float alpha = density * ex * ey * alphaMask * 0.80;
2428
+ if (alpha < 0.004) discard;
2429
+ gl_FragColor = vec4(color, alpha);
1147
2430
  }
1148
2431
  `,
1149
- side: THREE5__namespace.BackSide,
2432
+ transparent: true,
1150
2433
  depthWrite: false,
1151
- depthTest: true
2434
+ depthTest: true,
2435
+ side: THREE6__namespace.DoubleSide,
2436
+ blending: THREE6__namespace.AdditiveBlending
1152
2437
  });
1153
- const atm = new THREE5__namespace.Mesh(geometry, material);
1154
- atmosphereMesh = atm;
1155
- groundGroup.add(atm);
2438
+ milkyWayMesh = new THREE6__namespace.Mesh(geo, mat);
2439
+ const mwDir = new THREE6__namespace.Vector3(-0.62, 0.6, -0.5).normalize();
2440
+ milkyWayMesh.position.copy(mwDir.clone().multiplyScalar(920));
2441
+ milkyWayMesh.lookAt(0, 0, 0);
2442
+ milkyWayMesh.rotateY(Math.PI);
2443
+ milkyWayMesh.frustumCulled = false;
2444
+ milkyWayMesh.renderOrder = 1;
2445
+ scene.add(milkyWayMesh);
1156
2446
  }
1157
- const backdropGroup = new THREE5__namespace.Group();
2447
+ const backdropGroup = new THREE6__namespace.Group();
1158
2448
  scene.add(backdropGroup);
1159
- function createBackdropStars(count = 31e3) {
2449
+ let backdropStarsMaterial = null;
2450
+ function createBackdropStars(count = 5e3) {
1160
2451
  backdropGroup.clear();
1161
2452
  while (backdropGroup.children.length > 0) {
1162
2453
  const c = backdropGroup.children[0];
@@ -1164,7 +2455,7 @@ function createEngine({
1164
2455
  if (c.geometry) c.geometry.dispose();
1165
2456
  if (c.material) c.material.dispose();
1166
2457
  }
1167
- const geometry = new THREE5__namespace.BufferGeometry();
2458
+ const geometry = new THREE6__namespace.BufferGeometry();
1168
2459
  const positions = [];
1169
2460
  const sizes = [];
1170
2461
  const colors = [];
@@ -1199,14 +2490,18 @@ function createEngine({
1199
2490
  }
1200
2491
  colors.push(cr, cg, cb);
1201
2492
  }
1202
- geometry.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(positions, 3));
1203
- geometry.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(sizes, 1));
1204
- geometry.setAttribute("color", new THREE5__namespace.Float32BufferAttribute(colors, 3));
2493
+ geometry.setAttribute("position", new THREE6__namespace.Float32BufferAttribute(positions, 3));
2494
+ geometry.setAttribute("size", new THREE6__namespace.Float32BufferAttribute(sizes, 1));
2495
+ geometry.setAttribute("color", new THREE6__namespace.Float32BufferAttribute(colors, 3));
1205
2496
  const material = createSmartMaterial({
1206
2497
  uniforms: {
1207
2498
  pixelRatio: { value: renderer.getPixelRatio() },
1208
2499
  uScale: globalUniforms.uScale,
1209
- uTime: globalUniforms.uTime
2500
+ uTime: globalUniforms.uTime,
2501
+ uBackdropGain: { value: 1 },
2502
+ uBackdropEnergy: { value: 2.2 },
2503
+ uBackdropSizeExp: { value: 0.9 },
2504
+ uRevealZoom: { value: 0 }
1210
2505
  },
1211
2506
  vertexShaderBody: `
1212
2507
  attribute float size;
@@ -1217,6 +2512,10 @@ function createEngine({
1217
2512
  uniform float uAtmExtinction;
1218
2513
  uniform float uAtmTwinkle;
1219
2514
  uniform float uTime;
2515
+ uniform float uBackdropGain;
2516
+ uniform float uBackdropEnergy;
2517
+ uniform float uBackdropSizeExp;
2518
+ uniform float uRevealZoom;
1220
2519
 
1221
2520
  void main() {
1222
2521
  vec3 nPos = normalize(position);
@@ -1232,15 +2531,21 @@ function createEngine({
1232
2531
  float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1233
2532
  float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
1234
2533
 
1235
- vColor = color * 3.0 * extinction * horizonFade * scintillation;
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;
1236
2540
 
1237
2541
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1238
2542
  gl_Position = smartProject(mvPosition);
1239
2543
  vScreenPos = gl_Position.xy / gl_Position.w;
1240
2544
 
1241
- float zoomScale = pow(uScale, 0.5);
2545
+ float zoomScale = pow(max(uScale, 0.0001), uBackdropSizeExp);
1242
2546
  float perceptualSize = pow(size, 0.55);
1243
- gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
2547
+ float sizeGain = mix(0.78, 1.0, uBackdropGain);
2548
+ gl_PointSize = clamp(perceptualSize * zoomScale * sizeGain * 0.5 * pixelRatio * (800.0 / length(mvPosition.xyz)) * horizonFade, 0.5, 20.0);
1244
2549
  }
1245
2550
  `,
1246
2551
  fragmentShader: `
@@ -1264,27 +2569,34 @@ function createEngine({
1264
2569
  transparent: true,
1265
2570
  depthWrite: false,
1266
2571
  depthTest: true,
1267
- blending: THREE5__namespace.AdditiveBlending
2572
+ blending: THREE6__namespace.AdditiveBlending
1268
2573
  });
1269
- const points = new THREE5__namespace.Points(geometry, material);
2574
+ backdropStarsMaterial = material;
2575
+ const points = new THREE6__namespace.Points(geometry, material);
1270
2576
  points.frustumCulled = false;
1271
2577
  backdropGroup.add(points);
1272
2578
  }
2579
+ createSkyBackground();
1273
2580
  createGround();
1274
2581
  createAtmosphere();
2582
+ createMoon();
2583
+ createSun();
2584
+ createMilkyWay();
1275
2585
  createBackdropStars();
1276
- const raycaster = new THREE5__namespace.Raycaster();
2586
+ const raycaster = new THREE6__namespace.Raycaster();
1277
2587
  raycaster.params.Points.threshold = 5;
1278
- new THREE5__namespace.Vector2();
1279
- const root = new THREE5__namespace.Group();
2588
+ new THREE6__namespace.Vector2();
2589
+ const root = new THREE6__namespace.Group();
1280
2590
  scene.add(root);
1281
2591
  const nodeById = /* @__PURE__ */ new Map();
1282
2592
  const starIndexToId = [];
2593
+ const starIdToIndex = /* @__PURE__ */ new Map();
1283
2594
  const dynamicLabels = [];
2595
+ const labelManager = new LabelManager();
1284
2596
  const hoverLabelMat = createSmartMaterial({
1285
2597
  uniforms: {
1286
2598
  uMap: { value: null },
1287
- uSize: { value: new THREE5__namespace.Vector2(1, 1) },
2599
+ uSize: { value: new THREE6__namespace.Vector2(1, 1) },
1288
2600
  uAlpha: { value: 0 },
1289
2601
  uAngle: { value: 0 }
1290
2602
  },
@@ -1322,7 +2634,7 @@ function createEngine({
1322
2634
  depthTest: false
1323
2635
  // Always on top of stars
1324
2636
  });
1325
- const hoverLabelMesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), hoverLabelMat);
2637
+ const hoverLabelMesh = new THREE6__namespace.Mesh(new THREE6__namespace.PlaneGeometry(1, 1), hoverLabelMat);
1326
2638
  hoverLabelMesh.visible = false;
1327
2639
  hoverLabelMesh.renderOrder = 999;
1328
2640
  hoverLabelMesh.frustumCulled = false;
@@ -1346,7 +2658,9 @@ function createEngine({
1346
2658
  }
1347
2659
  nodeById.clear();
1348
2660
  starIndexToId.length = 0;
2661
+ starIdToIndex.clear();
1349
2662
  dynamicLabels.length = 0;
2663
+ labelManager.clear();
1350
2664
  constellationLines = null;
1351
2665
  boundaryLines = null;
1352
2666
  starPoints = null;
@@ -1368,49 +2682,132 @@ function createEngine({
1368
2682
  ctx.textAlign = "center";
1369
2683
  ctx.textBaseline = "middle";
1370
2684
  ctx.fillText(text, w / 2, h / 2);
1371
- const tex = new THREE5__namespace.CanvasTexture(canvas);
1372
- tex.minFilter = THREE5__namespace.LinearFilter;
2685
+ const tex = new THREE6__namespace.CanvasTexture(canvas);
2686
+ tex.minFilter = THREE6__namespace.LinearFilter;
1373
2687
  return { tex, aspect: w / h };
1374
2688
  }
1375
2689
  function getPosition(n) {
1376
2690
  if (currentConfig?.arrangement) {
1377
2691
  const arr = currentConfig.arrangement[n.id];
1378
- if (arr) {
1379
- if (arr.position[2] === 0) {
1380
- const x = arr.position[0];
1381
- const y = arr.position[1];
2692
+ if (arr?.position) {
2693
+ const [px, py, pz] = arr.position;
2694
+ if (pz === 0) {
1382
2695
  const radius = currentConfig.layout?.radius ?? 2e3;
1383
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
1384
- const phi = Math.atan2(y, x);
1385
- const theta = r_norm * (Math.PI / 2);
1386
- return new THREE5__namespace.Vector3(
1387
- Math.sin(theta) * Math.cos(phi),
1388
- Math.cos(theta),
1389
- Math.sin(theta) * Math.sin(phi)
1390
- ).multiplyScalar(radius);
2696
+ const len3d = Math.sqrt(px * px + py * py);
2697
+ if (len3d < radius * 0.99) {
2698
+ const r_norm = Math.min(1, len3d / radius);
2699
+ const phi = Math.atan2(py, px);
2700
+ const theta = r_norm * (Math.PI / 2);
2701
+ return new THREE6__namespace.Vector3(
2702
+ Math.sin(theta) * Math.cos(phi),
2703
+ Math.cos(theta),
2704
+ Math.sin(theta) * Math.sin(phi)
2705
+ ).multiplyScalar(radius);
2706
+ }
1391
2707
  }
1392
- return new THREE5__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2708
+ return new THREE6__namespace.Vector3(px, py, pz);
1393
2709
  }
1394
2710
  }
1395
- return new THREE5__namespace.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
2711
+ return new THREE6__namespace.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
1396
2712
  }
1397
2713
  function getBoundaryPoint(angle, t, radius) {
1398
2714
  const y = 0.05 + t * (1 - 0.05);
1399
2715
  const rY = Math.sqrt(1 - y * y);
1400
2716
  const x = Math.cos(angle) * rY;
1401
2717
  const z = Math.sin(angle) * rY;
1402
- return new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
2718
+ return new THREE6__namespace.Vector3(x, y, z).multiplyScalar(radius);
2719
+ }
2720
+ function updateChapterLabelAnchors() {
2721
+ if (!starPoints) return;
2722
+ const attr = starPoints.geometry.attributes.position;
2723
+ if (!attr) return;
2724
+ const cameraUpWorld = new THREE6__namespace.Vector3(0, 1, 0).applyQuaternion(camera.quaternion).normalize();
2725
+ const cameraRightWorld = new THREE6__namespace.Vector3(1, 0, 0).applyQuaternion(camera.quaternion).normalize();
2726
+ for (const item of dynamicLabels) {
2727
+ if (item.node.level !== 3) continue;
2728
+ const idx = starIdToIndex.get(item.node.id);
2729
+ if (idx === void 0) continue;
2730
+ const starPos = new THREE6__namespace.Vector3(attr.getX(idx), attr.getY(idx), attr.getZ(idx));
2731
+ const normal = starPos.clone().normalize();
2732
+ const tangent = cameraUpWorld.clone().sub(normal.clone().multiplyScalar(cameraUpWorld.dot(normal)));
2733
+ if (tangent.lengthSq() < 1e-6) {
2734
+ tangent.copy(cameraRightWorld).sub(normal.clone().multiplyScalar(cameraRightWorld.dot(normal)));
2735
+ }
2736
+ if (tangent.lengthSq() < 1e-6) continue;
2737
+ tangent.normalize();
2738
+ const starNorm = item.chapterStarSizeNorm ?? 0.5;
2739
+ const baseSize = item.chapterStarBaseSize ?? 3.5;
2740
+ const altitude = normal.y;
2741
+ const horizonFade = THREE6__namespace.MathUtils.smoothstep(altitude, -0.1, 0.05);
2742
+ const mvPos = starPos.clone().applyMatrix4(camera.matrixWorldInverse);
2743
+ const dist = Math.max(1, mvPos.length());
2744
+ const perceptualSize = Math.pow(baseSize, 0.7);
2745
+ const sizeBoost = 1 + Math.pow(baseSize, 0.5) * 0.08;
2746
+ const pointSize = THREE6__namespace.MathUtils.clamp(
2747
+ perceptualSize * sizeBoost * 20 * globalUniforms.uScale.value * renderer.getPixelRatio() * (2e3 / dist) * horizonFade,
2748
+ 1,
2749
+ 600
2750
+ );
2751
+ item.chapterGlowRadiusPx = pointSize * 0.6;
2752
+ const viewportH = Math.max(1, renderer.domElement.clientHeight);
2753
+ const fovRad = state.fov * Math.PI / 180;
2754
+ const worldPerPixel = 2 * dist * Math.tan(fovRad * 0.5) / viewportH;
2755
+ let labelHalfDiagPx = 18;
2756
+ const mat = item.obj.material;
2757
+ if (mat instanceof THREE6__namespace.ShaderMaterial && mat.uniforms?.uSize?.value instanceof THREE6__namespace.Vector2) {
2758
+ const uAlpha = typeof mat.uniforms.uAlpha?.value === "number" ? mat.uniforms.uAlpha.value : 0;
2759
+ const revealT = THREE6__namespace.MathUtils.smoothstep(uAlpha, 0, 1);
2760
+ const revealScale = 0.82 + 0.28 * revealT;
2761
+ const fadeOutScale = 1 + (1 - revealT) * 0.06;
2762
+ const zoomTextBoost = THREE6__namespace.MathUtils.lerp(1.4, 0.55, THREE6__namespace.MathUtils.smoothstep(state.fov, 8, 46));
2763
+ const starTextBoost = THREE6__namespace.MathUtils.lerp(0.9, 1.35, starNorm);
2764
+ const scaleMul = zoomTextBoost * starTextBoost * revealScale * fadeOutScale;
2765
+ const uSize = mat.uniforms.uSize.value;
2766
+ const targetX = item.initialScale.x * scaleMul;
2767
+ const targetY = item.initialScale.y * scaleMul;
2768
+ uSize.x = THREE6__namespace.MathUtils.lerp(uSize.x, targetX, 0.2);
2769
+ uSize.y = THREE6__namespace.MathUtils.lerp(uSize.y, targetY, 0.2);
2770
+ const size = mat.uniforms.uSize.value;
2771
+ const pixelH = size.y * viewportH * 0.8;
2772
+ const pixelW = size.x * viewportH * 0.8;
2773
+ labelHalfDiagPx = Math.max(6, Math.max(pixelH, pixelW * 0.45) * 0.5);
2774
+ }
2775
+ const edgeMarginPx = THREE6__namespace.MathUtils.lerp(1, 3, starNorm);
2776
+ const requiredPx = item.chapterGlowRadiusPx + edgeMarginPx + labelHalfDiagPx;
2777
+ const zoomPush = 1 + (1 - THREE6__namespace.MathUtils.smoothstep(state.fov, 8, 30)) * 0.8;
2778
+ const starPush = THREE6__namespace.MathUtils.lerp(0.95, 1.2, starNorm);
2779
+ const offset = THREE6__namespace.MathUtils.clamp(requiredPx * worldPerPixel * zoomPush * starPush, 3, 76);
2780
+ item.obj.position.copy(starPos);
2781
+ item.obj.position.addScaledVector(tangent, offset);
2782
+ item.obj.position.addScaledVector(normal, 2.5);
2783
+ item.chapterStarWorldPos = starPos.clone();
2784
+ }
2785
+ for (const item of dynamicLabels) {
2786
+ const level = item.node.level;
2787
+ if (level !== 2 && level !== 2.5) continue;
2788
+ const mat = item.obj.material;
2789
+ if (!(mat instanceof THREE6__namespace.ShaderMaterial) || !(mat.uniforms?.uSize?.value instanceof THREE6__namespace.Vector2)) continue;
2790
+ const entryFov = 22;
2791
+ const zoomBoost = THREE6__namespace.MathUtils.lerp(1.3, 0.5, THREE6__namespace.MathUtils.smoothstep(state.fov, 8, entryFov));
2792
+ const uAlpha = typeof mat.uniforms.uAlpha?.value === "number" ? mat.uniforms.uAlpha.value : 0;
2793
+ const revealT = THREE6__namespace.MathUtils.smoothstep(uAlpha, 0, 1);
2794
+ const revealScale = 0.82 + 0.28 * revealT;
2795
+ const scaleMul = zoomBoost * revealScale;
2796
+ const uSize = mat.uniforms.uSize.value;
2797
+ uSize.x = THREE6__namespace.MathUtils.lerp(uSize.x, item.initialScale.x * scaleMul, 0.2);
2798
+ uSize.y = THREE6__namespace.MathUtils.lerp(uSize.y, item.initialScale.y * scaleMul, 0.2);
2799
+ }
1403
2800
  }
1404
2801
  function buildFromModel(model, cfg) {
1405
2802
  clearRoot();
1406
2803
  bookIdToIndex.clear();
1407
2804
  testamentToIndex.clear();
1408
2805
  divisionToIndex.clear();
1409
- scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5__namespace.Color(cfg.background) : new THREE5__namespace.Color(0);
2806
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE6__namespace.Color(cfg.background) : null;
1410
2807
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1411
2808
  const laidOut = computeLayoutPositions(model, layoutCfg);
1412
2809
  const divisionPositions = /* @__PURE__ */ new Map();
1413
- if (cfg.arrangement) {
2810
+ {
1414
2811
  const divMap = /* @__PURE__ */ new Map();
1415
2812
  for (const n of laidOut.nodes) {
1416
2813
  if (n.level === 2 && n.parent) {
@@ -1420,7 +2817,7 @@ function createEngine({
1420
2817
  }
1421
2818
  }
1422
2819
  for (const [divId, books] of divMap.entries()) {
1423
- const centroid = new THREE5__namespace.Vector3();
2820
+ const centroid = new THREE6__namespace.Vector3();
1424
2821
  let count = 0;
1425
2822
  for (const b of books) {
1426
2823
  const p = getPosition(b);
@@ -1441,20 +2838,26 @@ function createEngine({
1441
2838
  const starChapterIndices = [];
1442
2839
  const starTestamentIndices = [];
1443
2840
  const starDivisionIndices = [];
2841
+ const starRevealThresholds = [];
2842
+ const chapterLineCutById = /* @__PURE__ */ new Map();
2843
+ const chapterStarSizeById = /* @__PURE__ */ new Map();
2844
+ const chapterWeightNormById = /* @__PURE__ */ new Map();
2845
+ let minChapterStarSize = Infinity;
2846
+ let maxChapterStarSize = -Infinity;
1444
2847
  const SPECTRAL_COLORS = [
1445
- new THREE5__namespace.Color(14544639),
2848
+ new THREE6__namespace.Color(14544639),
1446
2849
  // O - Blueish White
1447
- new THREE5__namespace.Color(15660287),
2850
+ new THREE6__namespace.Color(15660287),
1448
2851
  // B - White
1449
- new THREE5__namespace.Color(16317695),
2852
+ new THREE6__namespace.Color(16317695),
1450
2853
  // A - White
1451
- new THREE5__namespace.Color(16777208),
2854
+ new THREE6__namespace.Color(16777208),
1452
2855
  // F - White
1453
- new THREE5__namespace.Color(16775406),
2856
+ new THREE6__namespace.Color(16775406),
1454
2857
  // G - Yellowish White
1455
- new THREE5__namespace.Color(16773085),
2858
+ new THREE6__namespace.Color(16773085),
1456
2859
  // K - Pale Orange
1457
- new THREE5__namespace.Color(16771788)
2860
+ new THREE6__namespace.Color(16771788)
1458
2861
  // M - Light Orange
1459
2862
  ];
1460
2863
  let minWeight = Infinity;
@@ -1472,17 +2875,59 @@ function createEngine({
1472
2875
  } else if (minWeight === maxWeight) {
1473
2876
  maxWeight = minWeight + 1;
1474
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
+ }
1475
2889
  for (const n of laidOut.nodes) {
1476
2890
  if (n.level === 3) {
1477
- const p = getPosition(n);
1478
- starPositions.push(p.x, p.y, p.z);
1479
- starIndexToId.push(n.id);
1480
2891
  let baseSize = 3.5;
2892
+ let weightNorm = 0;
1481
2893
  if (typeof n.weight === "number") {
1482
- const t = (n.weight - minWeight) / (maxWeight - minWeight);
1483
- baseSize = 0.1 + Math.pow(t, 0.5) * 11.9;
2894
+ weightNorm = THREE6__namespace.MathUtils.clamp((n.weight - minWeight) / (maxWeight - minWeight), 0, 1);
2895
+ const sizeExp = cfg.starSizeExponent ?? 4;
2896
+ const sizeScale = cfg.starSizeScale ?? 6;
2897
+ baseSize = Math.pow(weightNorm, sizeExp) * 22 * sizeScale;
1484
2898
  }
2899
+ chapterStarSizeById.set(n.id, baseSize);
2900
+ chapterWeightNormById.set(n.id, weightNorm);
2901
+ minChapterStarSize = Math.min(minChapterStarSize, baseSize);
2902
+ maxChapterStarSize = Math.max(maxChapterStarSize, baseSize);
2903
+ }
2904
+ }
2905
+ if (!Number.isFinite(minChapterStarSize)) {
2906
+ minChapterStarSize = 1;
2907
+ maxChapterStarSize = 2;
2908
+ } else if (minChapterStarSize === maxChapterStarSize) {
2909
+ maxChapterStarSize = minChapterStarSize + 1;
2910
+ }
2911
+ for (const n of laidOut.nodes) {
2912
+ if (n.level === 3) {
2913
+ const p = getPosition(n);
2914
+ starPositions.push(p.x, p.y, p.z);
2915
+ starIdToIndex.set(n.id, starIndexToId.length);
2916
+ starIndexToId.push(n.id);
2917
+ const baseSize = chapterStarSizeById.get(n.id) ?? 3.5;
1485
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
+ }
2927
+ chapterLineCutById.set(
2928
+ n.id,
2929
+ THREE6__namespace.MathUtils.clamp(2.5 + baseSize * 0.45, 3, 40)
2930
+ );
1486
2931
  const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
1487
2932
  const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
1488
2933
  starColors.push(c.r, c.g, c.b);
@@ -1533,8 +2978,11 @@ function createEngine({
1533
2978
  let baseScale = 0.05;
1534
2979
  if (n.level === 1) baseScale = 0.08;
1535
2980
  else if (n.level === 2) baseScale = 0.04;
1536
- else if (n.level === 3) baseScale = 0.03;
1537
- const size = new THREE5__namespace.Vector2(baseScale * texRes.aspect, baseScale);
2981
+ else if (n.level === 3) {
2982
+ const wn2 = chapterWeightNormById.get(n.id) ?? 0;
2983
+ baseScale = THREE6__namespace.MathUtils.lerp(0.019, 0.039, wn2);
2984
+ }
2985
+ const size = new THREE6__namespace.Vector2(baseScale * texRes.aspect, baseScale);
1538
2986
  const mat = createSmartMaterial({
1539
2987
  uniforms: {
1540
2988
  uMap: { value: texRes.tex },
@@ -1573,39 +3021,60 @@ function createEngine({
1573
3021
  `,
1574
3022
  transparent: true,
1575
3023
  depthWrite: false,
1576
- depthTest: true
3024
+ depthTest: n.level === 3 ? false : true
1577
3025
  });
1578
- const mesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), mat);
3026
+ const mesh = new THREE6__namespace.Mesh(new THREE6__namespace.PlaneGeometry(1, 1), mat);
1579
3027
  let p = getPosition(n);
1580
3028
  if (n.level === 1) {
1581
- if (divisionPositions.has(n.id)) {
1582
- p.copy(divisionPositions.get(n.id));
3029
+ if (cfg.arrangement?.[n.id]) {
3030
+ const arr = cfg.arrangement[n.id];
3031
+ p.set(arr.position[0], arr.position[1], arr.position[2]);
3032
+ } else {
3033
+ if (divisionPositions.has(n.id)) {
3034
+ p.copy(divisionPositions.get(n.id));
3035
+ }
3036
+ const r = layoutCfg.radius * 0.95;
3037
+ const angle = Math.atan2(p.z, p.x);
3038
+ p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1583
3039
  }
1584
- const r = layoutCfg.radius * 0.95;
1585
- const angle = Math.atan2(p.z, p.x);
1586
- p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1587
3040
  } else if (n.level === 3) {
1588
- p.y += 30;
1589
- p.multiplyScalar(1.001);
3041
+ const starSize = chapterStarSizeById.get(n.id) ?? 3.5;
3042
+ const starNorm = THREE6__namespace.MathUtils.clamp(
3043
+ (starSize - minChapterStarSize) / (maxChapterStarSize - minChapterStarSize),
3044
+ 0,
3045
+ 1
3046
+ );
3047
+ const radialOffset = THREE6__namespace.MathUtils.lerp(16, 46, starNorm);
3048
+ p.addScaledVector(p.clone().normalize(), radialOffset);
1590
3049
  }
1591
3050
  mesh.position.set(p.x, p.y, p.z);
1592
3051
  mesh.scale.set(size.x, size.y, 1);
1593
3052
  mesh.frustumCulled = false;
1594
3053
  mesh.userData = { id: n.id };
1595
3054
  root.add(mesh);
1596
- dynamicLabels.push({ obj: mesh, node: n, initialScale: size.clone() });
3055
+ const wn = n.level === 3 ? chapterWeightNormById.get(n.id) ?? 0 : 0;
3056
+ const chapterMaxFovBias = n.level === 3 ? THREE6__namespace.MathUtils.lerp(-4, 8, wn) : 0;
3057
+ dynamicLabels.push({
3058
+ obj: mesh,
3059
+ node: n,
3060
+ initialScale: size.clone(),
3061
+ maxFovBias: chapterMaxFovBias,
3062
+ chapterStarSizeNorm: n.level === 3 ? wn : void 0,
3063
+ chapterStarBaseSize: n.level === 3 ? chapterStarSizeById.get(n.id) ?? 3.5 : void 0
3064
+ });
1597
3065
  }
1598
3066
  }
1599
3067
  }
1600
- const starGeo = new THREE5__namespace.BufferGeometry();
1601
- starGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(starPositions, 3));
1602
- starGeo.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(starSizes, 1));
1603
- starGeo.setAttribute("color", new THREE5__namespace.Float32BufferAttribute(starColors, 3));
1604
- starGeo.setAttribute("phase", new THREE5__namespace.Float32BufferAttribute(starPhases, 1));
1605
- starGeo.setAttribute("bookIndex", new THREE5__namespace.Float32BufferAttribute(starBookIndices, 1));
1606
- starGeo.setAttribute("chapterIndex", new THREE5__namespace.Float32BufferAttribute(starChapterIndices, 1));
1607
- starGeo.setAttribute("testamentIndex", new THREE5__namespace.Float32BufferAttribute(starTestamentIndices, 1));
1608
- starGeo.setAttribute("divisionIndex", new THREE5__namespace.Float32BufferAttribute(starDivisionIndices, 1));
3068
+ const starGeo = new THREE6__namespace.BufferGeometry();
3069
+ starGeo.setAttribute("position", new THREE6__namespace.Float32BufferAttribute(starPositions, 3));
3070
+ starGeo.setAttribute("size", new THREE6__namespace.Float32BufferAttribute(starSizes, 1));
3071
+ starGeo.setAttribute("color", new THREE6__namespace.Float32BufferAttribute(starColors, 3));
3072
+ starGeo.setAttribute("phase", new THREE6__namespace.Float32BufferAttribute(starPhases, 1));
3073
+ starGeo.setAttribute("bookIndex", new THREE6__namespace.Float32BufferAttribute(starBookIndices, 1));
3074
+ starGeo.setAttribute("chapterIndex", new THREE6__namespace.Float32BufferAttribute(starChapterIndices, 1));
3075
+ starGeo.setAttribute("testamentIndex", new THREE6__namespace.Float32BufferAttribute(starTestamentIndices, 1));
3076
+ starGeo.setAttribute("divisionIndex", new THREE6__namespace.Float32BufferAttribute(starDivisionIndices, 1));
3077
+ starGeo.setAttribute("revealThreshold", new THREE6__namespace.Float32BufferAttribute(starRevealThresholds, 1));
1609
3078
  const starMat = createSmartMaterial({
1610
3079
  uniforms: {
1611
3080
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1614,7 +3083,7 @@ function createEngine({
1614
3083
  uActiveBookIndex: { value: -1 },
1615
3084
  uOrderRevealStrength: { value: 0 },
1616
3085
  uGlobalDimFactor: { value: ORDER_REVEAL_CONFIG.globalDim },
1617
- uPulseParams: { value: new THREE5__namespace.Vector3(
3086
+ uPulseParams: { value: new THREE6__namespace.Vector3(
1618
3087
  ORDER_REVEAL_CONFIG.pulseDuration,
1619
3088
  ORDER_REVEAL_CONFIG.delayPerChapter,
1620
3089
  ORDER_REVEAL_CONFIG.pulseAmplitude
@@ -1623,18 +3092,22 @@ function createEngine({
1623
3092
  uFilterDivisionIndex: { value: -1 },
1624
3093
  uFilterBookIndex: { value: -1 },
1625
3094
  uFilterStrength: { value: 0 },
1626
- uFilterDimFactor: { value: 0.08 }
3095
+ uFilterDimFactor: { value: 0.08 },
3096
+ uRevealZoom: { value: 0 }
1627
3097
  },
1628
3098
  vertexShaderBody: `
1629
- attribute float size;
1630
- attribute vec3 color;
3099
+ attribute float size;
3100
+ attribute vec3 color;
1631
3101
  attribute float phase;
1632
3102
  attribute float bookIndex;
1633
3103
  attribute float chapterIndex;
1634
3104
  attribute float testamentIndex;
1635
3105
  attribute float divisionIndex;
3106
+ attribute float revealThreshold;
1636
3107
 
1637
3108
  varying vec3 vColor;
3109
+ varying float vSize;
3110
+ varying float vReveal;
1638
3111
  uniform float pixelRatio;
1639
3112
 
1640
3113
  uniform float uTime;
@@ -1651,6 +3124,7 @@ function createEngine({
1651
3124
  uniform float uFilterBookIndex;
1652
3125
  uniform float uFilterStrength;
1653
3126
  uniform float uFilterDimFactor;
3127
+ uniform float uRevealZoom;
1654
3128
 
1655
3129
  void main() {
1656
3130
  vec3 nPos = normalize(position);
@@ -1707,41 +3181,166 @@ function createEngine({
1707
3181
  gl_Position = smartProject(mvPosition);
1708
3182
  vScreenPos = gl_Position.xy / gl_Position.w;
1709
3183
 
1710
- float sizeBoost = 1.0 + activePulse * 0.8;
1711
- float perceptualSize = pow(size, 0.55);
1712
- gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
3184
+ float sizeBoost = 1.0 + activePulse * 0.15;
3185
+ // pow(size, 0.7) is gentler compression than 0.55 \u2014 preserves more of
3186
+ // the aggressive JS curve so large stars stay visually dominant.
3187
+ float perceptualSize = pow(size, 0.7);
3188
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 20.0) * uScale * pixelRatio * (2000.0 / length(mvPosition.xyz)) * horizonFade, 1.0, 600.0);
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);
1713
3195
  }
1714
3196
  `,
1715
3197
  fragmentShader: `
1716
- varying vec3 vColor;
1717
- void main() {
1718
- vec2 coord = gl_PointCoord - vec2(0.5);
1719
- float d = length(coord) * 2.0;
1720
- if (d > 1.0) discard;
1721
-
1722
- float alphaMask = getMaskAlpha();
1723
- if (alphaMask < 0.01) discard;
1724
-
1725
- // Stellarium-style dual-layer: sharp core + soft glow
1726
- float core = smoothstep(0.8, 0.4, d);
1727
- float glow = smoothstep(1.0, 0.0, d) * 0.08;
1728
- float k = core + glow;
3198
+ varying vec3 vColor;
3199
+ varying float vSize;
3200
+ varying float vReveal;
3201
+ void main() {
3202
+ vec2 coord = gl_PointCoord - vec2(0.5);
3203
+ float d = length(coord) * 2.0;
3204
+ if (d > 1.0) discard;
3205
+
3206
+ float alphaMask = getMaskAlpha();
3207
+ if (alphaMask < 0.01) discard;
3208
+
3209
+ // --- Multi-layer Gaussian star model ---
3210
+ // Tight white-hot core
3211
+ float core = exp(-d * d * 9.0);
3212
+ // Broader coloured inner halo
3213
+ float innerGlow = exp(-d * d * 3.0) * 0.45;
3214
+ // Wide faint bloom that fades smoothly to the disc edge
3215
+ float outerBloom = max(0.0, 1.0 - d * d) * 0.10;
3216
+
3217
+ float k = core + innerGlow + outerBloom;
1729
3218
 
1730
- // White-hot core blending into coloured halo
1731
- vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1732
- gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
3219
+ // White-hot centre \u2192 spectral colour at the halo
3220
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.88);
3221
+
3222
+ // --- Size-dependent diffraction spikes ---
3223
+ // Only appear on larger (brighter) stars, matching real optics.
3224
+ float spikeFactor = smoothstep(10.0, 24.0, vSize);
3225
+ float spikeH = exp(-coord.y * coord.y * 180.0) * exp(-abs(coord.x) * 6.0);
3226
+ float spikeV = exp(-coord.x * coord.x * 180.0) * exp(-abs(coord.y) * 6.0);
3227
+ float spikes = (spikeH + spikeV) * 0.18 * spikeFactor;
3228
+
3229
+ // vReveal drives the additive contribution (AdditiveBlending uses SRC_ALPHA).
3230
+ gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, vReveal);
1733
3231
  }
1734
3232
  `,
1735
3233
  transparent: true,
1736
3234
  depthWrite: false,
1737
3235
  depthTest: true,
1738
- blending: THREE5__namespace.AdditiveBlending
3236
+ blending: THREE6__namespace.AdditiveBlending
1739
3237
  });
1740
- starPoints = new THREE5__namespace.Points(starGeo, starMat);
3238
+ starPoints = new THREE6__namespace.Points(starGeo, starMat);
1741
3239
  starPoints.frustumCulled = false;
1742
3240
  root.add(starPoints);
1743
3241
  const linePoints = [];
3242
+ const lineWeights = [];
3243
+ const seenEdges = /* @__PURE__ */ new Set();
1744
3244
  const bookMap = /* @__PURE__ */ new Map();
3245
+ const parseBookKeyFromChapterId = (id) => {
3246
+ if (!id) return null;
3247
+ const parts = id.split(":");
3248
+ if (parts.length < 3 || parts[0] !== "C") return null;
3249
+ return parts[1] || null;
3250
+ };
3251
+ const weightScaleFromLabel = (weight) => {
3252
+ if (weight === "thin") return 0.65;
3253
+ if (weight === "bold") return 1.6;
3254
+ return 1;
3255
+ };
3256
+ const edgeKey = (aNodeId, bNodeId) => aNodeId < bNodeId ? `${aNodeId}|${bNodeId}` : `${bNodeId}|${aNodeId}`;
3257
+ const addTruncatedSegment = (aNodeId, bNodeId, weightScale) => {
3258
+ if (aNodeId === bNodeId) return;
3259
+ const k = edgeKey(aNodeId, bNodeId);
3260
+ if (seenEdges.has(k)) return;
3261
+ seenEdges.add(k);
3262
+ const aNode = nodeById.get(aNodeId);
3263
+ const bNode = nodeById.get(bNodeId);
3264
+ if (!aNode || !bNode) return;
3265
+ const p1 = getPosition(aNode);
3266
+ const p2 = getPosition(bNode);
3267
+ const dir = new THREE6__namespace.Vector3().subVectors(p2, p1);
3268
+ const len = dir.length();
3269
+ if (len < 1e-3) return;
3270
+ dir.divideScalar(len);
3271
+ let cutA = chapterLineCutById.get(aNodeId) ?? 4;
3272
+ let cutB = chapterLineCutById.get(bNodeId) ?? 4;
3273
+ const maxTotalCut = len * 0.8;
3274
+ const totalCut = cutA + cutB;
3275
+ if (totalCut > maxTotalCut && totalCut > 0) {
3276
+ const scale = maxTotalCut / totalCut;
3277
+ cutA *= scale;
3278
+ cutB *= scale;
3279
+ }
3280
+ const a = p1.clone().addScaledVector(dir, cutA);
3281
+ const b = p2.clone().addScaledVector(dir, -cutB);
3282
+ linePoints.push(a.x, a.y, a.z);
3283
+ linePoints.push(b.x, b.y, b.z);
3284
+ lineWeights.push(weightScale);
3285
+ };
3286
+ const customBooks = /* @__PURE__ */ new Set();
3287
+ const rawConstellations = cfg.constellations && Array.isArray(cfg.constellations.constellations) ? cfg.constellations.constellations : [];
3288
+ for (const c of rawConstellations) {
3289
+ const linePaths = Array.isArray(c?.linePaths) ? c.linePaths : [];
3290
+ const lineSegments = Array.isArray(c?.lineSegments) ? c.lineSegments : [];
3291
+ if (linePaths.length === 0 && lineSegments.length === 0) continue;
3292
+ const anchorBookKey = parseBookKeyFromChapterId(c?.anchors?.[0]);
3293
+ if (anchorBookKey) customBooks.add(anchorBookKey);
3294
+ for (const segDef of lineSegments) {
3295
+ let from;
3296
+ let to;
3297
+ let weightLabel;
3298
+ if (Array.isArray(segDef)) {
3299
+ const raw = segDef;
3300
+ if (typeof raw[0] === "string" && (raw[0] === "thin" || raw[0] === "bold" || raw[0] === "normal")) {
3301
+ weightLabel = raw[0];
3302
+ from = typeof raw[1] === "string" ? raw[1] : void 0;
3303
+ to = typeof raw[2] === "string" ? raw[2] : void 0;
3304
+ } else {
3305
+ from = typeof raw[0] === "string" ? raw[0] : void 0;
3306
+ to = typeof raw[1] === "string" ? raw[1] : void 0;
3307
+ }
3308
+ } else if (segDef) {
3309
+ from = typeof segDef.from === "string" ? segDef.from : void 0;
3310
+ to = typeof segDef.to === "string" ? segDef.to : void 0;
3311
+ weightLabel = typeof segDef.weight === "string" ? segDef.weight : void 0;
3312
+ }
3313
+ if (!from || !to) continue;
3314
+ const k1 = parseBookKeyFromChapterId(from);
3315
+ const k2 = parseBookKeyFromChapterId(to);
3316
+ if (k1) customBooks.add(k1);
3317
+ if (k2) customBooks.add(k2);
3318
+ addTruncatedSegment(from, to, weightScaleFromLabel(weightLabel));
3319
+ }
3320
+ for (const pathDef of linePaths) {
3321
+ let nodes = [];
3322
+ let weightLabel = void 0;
3323
+ if (Array.isArray(pathDef)) {
3324
+ const raw = pathDef;
3325
+ if (typeof raw[0] === "string" && (raw[0] === "thin" || raw[0] === "bold" || raw[0] === "normal")) {
3326
+ weightLabel = raw[0];
3327
+ nodes = raw.slice(1).filter((v) => typeof v === "string");
3328
+ } else {
3329
+ nodes = raw.filter((v) => typeof v === "string");
3330
+ }
3331
+ } else if (pathDef && Array.isArray(pathDef.nodes)) {
3332
+ nodes = pathDef.nodes.filter((v) => typeof v === "string");
3333
+ weightLabel = typeof pathDef.weight === "string" ? pathDef.weight : void 0;
3334
+ }
3335
+ if (nodes.length < 2) continue;
3336
+ const inferredBookKey = parseBookKeyFromChapterId(nodes[0]);
3337
+ if (inferredBookKey) customBooks.add(inferredBookKey);
3338
+ const w = weightScaleFromLabel(weightLabel);
3339
+ for (let i = 0; i < nodes.length - 1; i++) {
3340
+ addTruncatedSegment(nodes[i], nodes[i + 1], w);
3341
+ }
3342
+ }
3343
+ }
1745
3344
  for (const n of laidOut.nodes) {
1746
3345
  if (n.level === 3 && n.parent) {
1747
3346
  const list = bookMap.get(n.parent) ?? [];
@@ -1752,24 +3351,27 @@ function createEngine({
1752
3351
  for (const chapters of bookMap.values()) {
1753
3352
  chapters.sort((a, b) => (a.meta?.chapter || 0) - (b.meta?.chapter || 0));
1754
3353
  if (chapters.length < 2) continue;
3354
+ const bookKey = chapters[0]?.meta?.bookKey ?? null;
3355
+ if (bookKey && customBooks.has(bookKey)) continue;
1755
3356
  for (let i = 0; i < chapters.length - 1; i++) {
1756
3357
  const c1 = chapters[i];
1757
3358
  const c2 = chapters[i + 1];
1758
3359
  if (!c1 || !c2) continue;
1759
- const p1 = getPosition(c1);
1760
- const p2 = getPosition(c2);
1761
- linePoints.push(p1.x, p1.y, p1.z);
1762
- linePoints.push(p2.x, p2.y, p2.z);
3360
+ addTruncatedSegment(c1.id, c2.id, 1);
1763
3361
  }
1764
3362
  }
1765
3363
  if (linePoints.length > 0) {
1766
3364
  const quadPositions = [];
1767
3365
  const quadUvs = [];
3366
+ const quadLineWeight = [];
3367
+ const quadSegmentIndex = [];
1768
3368
  const quadIndices = [];
1769
3369
  const lineWidth = 8;
3370
+ const segmentCount = linePoints.length / 6;
1770
3371
  for (let i = 0; i < linePoints.length; i += 6) {
1771
3372
  const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1772
3373
  const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
3374
+ const segIndex = i / 6;
1773
3375
  const dx = bx - ax, dy = by - ay, dz = bz - az;
1774
3376
  const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1775
3377
  if (len < 1e-3) continue;
@@ -1794,23 +3396,36 @@ function createEngine({
1794
3396
  quadUvs.push(1, -1);
1795
3397
  quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1796
3398
  quadUvs.push(1, 1);
3399
+ const w = lineWeights[segIndex] ?? 1;
3400
+ quadLineWeight.push(w, w, w, w);
3401
+ quadSegmentIndex.push(segIndex, segIndex, segIndex, segIndex);
1797
3402
  quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1798
3403
  }
1799
- const lineGeo = new THREE5__namespace.BufferGeometry();
1800
- lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(quadPositions, 3));
1801
- lineGeo.setAttribute("lineUv", new THREE5__namespace.Float32BufferAttribute(quadUvs, 2));
3404
+ const lineGeo = new THREE6__namespace.BufferGeometry();
3405
+ lineGeo.setAttribute("position", new THREE6__namespace.Float32BufferAttribute(quadPositions, 3));
3406
+ lineGeo.setAttribute("lineUv", new THREE6__namespace.Float32BufferAttribute(quadUvs, 2));
3407
+ lineGeo.setAttribute("lineWeight", new THREE6__namespace.Float32BufferAttribute(quadLineWeight, 1));
3408
+ lineGeo.setAttribute("segmentIndex", new THREE6__namespace.Float32BufferAttribute(quadSegmentIndex, 1));
1802
3409
  lineGeo.setIndex(quadIndices);
1803
3410
  const lineMat = createSmartMaterial({
1804
3411
  uniforms: {
1805
- color: { value: new THREE5__namespace.Color(11193599) },
3412
+ color: { value: new THREE6__namespace.Color(11193599) },
1806
3413
  uLineWidth: { value: 1.5 },
1807
- uGlowIntensity: { value: 0.3 }
3414
+ uGlowIntensity: { value: 0.3 },
3415
+ uReveal: { value: 0 },
3416
+ uSegmentCount: { value: Math.max(1, segmentCount) }
1808
3417
  },
1809
3418
  vertexShaderBody: `
1810
3419
  attribute vec2 lineUv;
3420
+ attribute float lineWeight;
3421
+ attribute float segmentIndex;
1811
3422
  varying vec2 vLineUv;
3423
+ varying float vLineWeight;
3424
+ varying float vSegmentIndex;
1812
3425
  void main() {
1813
3426
  vLineUv = lineUv;
3427
+ vLineWeight = lineWeight;
3428
+ vSegmentIndex = segmentIndex;
1814
3429
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1815
3430
  gl_Position = smartProject(mvPosition);
1816
3431
  vScreenPos = gl_Position.xy / gl_Position.w;
@@ -1820,32 +3435,53 @@ function createEngine({
1820
3435
  uniform vec3 color;
1821
3436
  uniform float uLineWidth;
1822
3437
  uniform float uGlowIntensity;
3438
+ uniform float uReveal;
3439
+ uniform float uSegmentCount;
1823
3440
  varying vec2 vLineUv;
3441
+ varying float vLineWeight;
3442
+ varying float vSegmentIndex;
1824
3443
  void main() {
1825
3444
  float alphaMask = getMaskAlpha();
1826
3445
  if (alphaMask < 0.01) discard;
1827
3446
 
3447
+ // Progressive line draw tuned closer to Stellarium feel:
3448
+ // - eased global reveal
3449
+ // - sequential segment staggering with slight overlap
3450
+ // - smooth growth of each segment endpoint
3451
+ float reveal = smoothstep(0.0, 1.0, uReveal);
3452
+ float segCount = max(uSegmentCount, 1.0);
3453
+ float segStart = vSegmentIndex / segCount;
3454
+ float segSpan = (1.25 / segCount) + 0.04;
3455
+ float localReveal = clamp((reveal - segStart) / segSpan, 0.0, 1.0);
3456
+ localReveal = smoothstep(0.0, 1.0, localReveal);
3457
+
3458
+ // Keep fragment only when x is before the animated endpoint.
3459
+ float endpointMask = 1.0 - smoothstep(localReveal - 0.03, localReveal + 0.02, vLineUv.x);
3460
+ // Fade in segment brightness as it begins drawing.
3461
+ float drawMask = endpointMask * smoothstep(0.0, 0.08, localReveal);
3462
+ if (drawMask < 0.001) discard;
3463
+
1828
3464
  float dist = abs(vLineUv.y);
1829
3465
 
1830
3466
  // Anti-aliased core line
1831
- float hw = uLineWidth * 0.05;
3467
+ float hw = (uLineWidth * vLineWeight) * 0.05;
1832
3468
  float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1833
3469
 
1834
3470
  // Soft glow extending outward
1835
- float glow = (1.0 - dist) * uGlowIntensity;
3471
+ float glow = (1.0 - dist) * uGlowIntensity * vLineWeight;
1836
3472
 
1837
3473
  float alpha = max(glow, base);
1838
3474
  if (alpha < 0.005) discard;
1839
3475
 
1840
- gl_FragColor = vec4(color, alpha * alphaMask);
3476
+ gl_FragColor = vec4(color, alpha * alphaMask * drawMask);
1841
3477
  }
1842
3478
  `,
1843
3479
  transparent: true,
1844
3480
  depthWrite: false,
1845
- blending: THREE5__namespace.AdditiveBlending,
1846
- side: THREE5__namespace.DoubleSide
3481
+ blending: THREE6__namespace.AdditiveBlending,
3482
+ side: THREE6__namespace.DoubleSide
1847
3483
  });
1848
- constellationLines = new THREE5__namespace.Mesh(lineGeo, lineMat);
3484
+ constellationLines = new THREE6__namespace.Mesh(lineGeo, lineMat);
1849
3485
  constellationLines.frustumCulled = false;
1850
3486
  root.add(constellationLines);
1851
3487
  }
@@ -1858,7 +3494,7 @@ function createEngine({
1858
3494
  if (groupList) {
1859
3495
  groupList.forEach((g, idx) => {
1860
3496
  const groupId = `G:${bookId}:${idx}`;
1861
- let p = new THREE5__namespace.Vector3();
3497
+ let p = new THREE6__namespace.Vector3();
1862
3498
  if (cfg.arrangement && cfg.arrangement[groupId]) {
1863
3499
  const arr = cfg.arrangement[groupId];
1864
3500
  p.set(arr.position[0], arr.position[1], arr.position[2]);
@@ -1877,7 +3513,7 @@ function createEngine({
1877
3513
  const texRes = createTextTexture(labelText, "#4fa4fa80");
1878
3514
  if (texRes) {
1879
3515
  const baseScale = 0.036;
1880
- const size = new THREE5__namespace.Vector2(baseScale * texRes.aspect, baseScale);
3516
+ const size = new THREE6__namespace.Vector2(baseScale * texRes.aspect, baseScale);
1881
3517
  const mat = createSmartMaterial({
1882
3518
  uniforms: {
1883
3519
  uMap: { value: texRes.tex },
@@ -1918,7 +3554,7 @@ function createEngine({
1918
3554
  depthWrite: false,
1919
3555
  depthTest: true
1920
3556
  });
1921
- const mesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), mat);
3557
+ const mesh = new THREE6__namespace.Mesh(new THREE6__namespace.PlaneGeometry(1, 1), mat);
1922
3558
  mesh.position.copy(p);
1923
3559
  mesh.scale.set(size.x, size.y, 1);
1924
3560
  mesh.frustumCulled = false;
@@ -1940,14 +3576,14 @@ function createEngine({
1940
3576
  const boundaries = laidOut.meta?.divisionBoundaries ?? [];
1941
3577
  if (boundaries.length > 0) {
1942
3578
  const boundaryMat = createSmartMaterial({
1943
- uniforms: { color: { value: new THREE5__namespace.Color(5601177) } },
3579
+ uniforms: { color: { value: new THREE6__namespace.Color(5601177) } },
1944
3580
  vertexShaderBody: `uniform vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
1945
3581
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
1946
3582
  transparent: true,
1947
3583
  depthWrite: false,
1948
- blending: THREE5__namespace.AdditiveBlending
3584
+ blending: THREE6__namespace.AdditiveBlending
1949
3585
  });
1950
- const boundaryGeo = new THREE5__namespace.BufferGeometry();
3586
+ const boundaryGeo = new THREE6__namespace.BufferGeometry();
1951
3587
  const bPoints = [];
1952
3588
  boundaries.forEach((angle) => {
1953
3589
  const steps = 32;
@@ -1960,8 +3596,8 @@ function createEngine({
1960
3596
  bPoints.push(p2.x, p2.y, p2.z);
1961
3597
  }
1962
3598
  });
1963
- boundaryGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(bPoints, 3));
1964
- boundaryLines = new THREE5__namespace.LineSegments(boundaryGeo, boundaryMat);
3599
+ boundaryGeo.setAttribute("position", new THREE6__namespace.Float32BufferAttribute(bPoints, 3));
3600
+ boundaryLines = new THREE6__namespace.LineSegments(boundaryGeo, boundaryMat);
1965
3601
  boundaryLines.frustumCulled = false;
1966
3602
  root.add(boundaryLines);
1967
3603
  }
@@ -1980,7 +3616,7 @@ function createEngine({
1980
3616
  const r_norm = Math.sqrt(x * x + y * y);
1981
3617
  const phi = Math.atan2(y, x);
1982
3618
  const theta = r_norm * (Math.PI / 2);
1983
- return new THREE5__namespace.Vector3(
3619
+ return new THREE6__namespace.Vector3(
1984
3620
  Math.sin(theta) * Math.cos(phi),
1985
3621
  Math.cos(theta),
1986
3622
  Math.sin(theta) * Math.sin(phi)
@@ -1993,22 +3629,23 @@ function createEngine({
1993
3629
  }
1994
3630
  }
1995
3631
  if (polyPoints.length > 0) {
1996
- const polyGeo = new THREE5__namespace.BufferGeometry();
1997
- polyGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(polyPoints, 3));
3632
+ const polyGeo = new THREE6__namespace.BufferGeometry();
3633
+ polyGeo.setAttribute("position", new THREE6__namespace.Float32BufferAttribute(polyPoints, 3));
1998
3634
  const polyMat = createSmartMaterial({
1999
- uniforms: { color: { value: new THREE5__namespace.Color(3718648) } },
3635
+ uniforms: { color: { value: new THREE6__namespace.Color(3718648) } },
2000
3636
  // Cyan-ish
2001
3637
  vertexShaderBody: `uniform vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
2002
3638
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
2003
3639
  transparent: true,
2004
3640
  depthWrite: false,
2005
- blending: THREE5__namespace.AdditiveBlending
3641
+ blending: THREE6__namespace.AdditiveBlending
2006
3642
  });
2007
- const polyLines = new THREE5__namespace.LineSegments(polyGeo, polyMat);
3643
+ const polyLines = new THREE6__namespace.LineSegments(polyGeo, polyMat);
2008
3644
  polyLines.frustumCulled = false;
2009
3645
  root.add(polyLines);
2010
3646
  }
2011
3647
  }
3648
+ labelManager.setLabels(dynamicLabels);
2012
3649
  resize();
2013
3650
  }
2014
3651
  let lastData = void 0;
@@ -2029,6 +3666,10 @@ function createEngine({
2029
3666
  }
2030
3667
  function setConfig(cfg) {
2031
3668
  currentConfig = cfg;
3669
+ applyGroundTheme(cfg);
3670
+ const externalFocusId = cfg.focus?.nodeId;
3671
+ if (typeof externalFocusId === "string") focusedNodeId = externalFocusId;
3672
+ if (externalFocusId === null) focusedNodeId = null;
2032
3673
  if (cfg.projection) setProjection(cfg.projection);
2033
3674
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
2034
3675
  state.lon = cfg.camera.lon;
@@ -2069,27 +3710,48 @@ function createEngine({
2069
3710
  if (lastModel) buildFromModel(lastModel, cfg);
2070
3711
  }
2071
3712
  if (cfg.constellations) {
3713
+ const getLayoutPosition = (id) => {
3714
+ const n = nodeById.get(id);
3715
+ if (!n) return null;
3716
+ const x = n.meta?.x ?? 0;
3717
+ const y = n.meta?.y ?? 0;
3718
+ const z = n.meta?.z ?? 0;
3719
+ if (z === 0) {
3720
+ const radius = cfg.layout?.radius ?? 2e3;
3721
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3722
+ const phi = Math.atan2(y, x);
3723
+ const theta = r_norm * (Math.PI / 2);
3724
+ return new THREE6__namespace.Vector3(
3725
+ Math.sin(theta) * Math.cos(phi),
3726
+ Math.cos(theta),
3727
+ Math.sin(theta) * Math.sin(phi)
3728
+ ).multiplyScalar(radius);
3729
+ }
3730
+ return new THREE6__namespace.Vector3(x, y, z);
3731
+ };
2072
3732
  constellationLayer.load(cfg.constellations, (id) => {
2073
3733
  if (cfg.arrangement && cfg.arrangement[id]) {
2074
3734
  const arr = cfg.arrangement[id];
2075
- if (arr.position[2] === 0) {
2076
- const x = arr.position[0];
2077
- const y = arr.position[1];
2078
- const radius = cfg.layout?.radius ?? 2e3;
2079
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
2080
- const phi = Math.atan2(y, x);
2081
- const theta = r_norm * (Math.PI / 2);
2082
- return new THREE5__namespace.Vector3(
2083
- Math.sin(theta) * Math.cos(phi),
2084
- Math.cos(theta),
2085
- Math.sin(theta) * Math.sin(phi)
2086
- ).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]);
2087
3751
  }
2088
- return new THREE5__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2089
3752
  }
2090
- const n = nodeById.get(id);
2091
- return n ? getPosition(n) : null;
2092
- });
3753
+ return getLayoutPosition(id);
3754
+ }, getLayoutPosition);
2093
3755
  }
2094
3756
  }
2095
3757
  function setHandlers(next) {
@@ -2114,7 +3776,7 @@ function createEngine({
2114
3776
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
2115
3777
  }
2116
3778
  for (const item of constellationLayer.getItems()) {
2117
- arr[item.config.id] = { position: [item.mesh.position.x, item.mesh.position.y, item.mesh.position.z] };
3779
+ arr[item.config.id] = { center: [item.center.x, item.center.y, item.center.z] };
2118
3780
  }
2119
3781
  Object.assign(arr, state.tempArrangement);
2120
3782
  return arr;
@@ -2138,60 +3800,70 @@ function createEngine({
2138
3800
  const uAspect = camera.aspect;
2139
3801
  const w = rect.width;
2140
3802
  const h = rect.height;
2141
- let closestLabel = null;
2142
- const LABEL_THRESHOLD = isTouchDevice ? 48 : 40;
2143
- let minLabelDist = LABEL_THRESHOLD;
2144
- for (const item of dynamicLabels) {
2145
- if (!item.obj.visible) continue;
2146
- if (isNodeFiltered(item.node)) continue;
2147
- const pWorld = item.obj.position;
2148
- const pProj = smartProjectJS(pWorld);
2149
- if (currentProjection.isClipped(pProj.z)) continue;
2150
- const xNDC = pProj.x * uScale / uAspect;
2151
- const yNDC = pProj.y * uScale;
2152
- const sX = (xNDC * 0.5 + 0.5) * w;
2153
- const sY = (-yNDC * 0.5 + 0.5) * h;
2154
- const dx = mX - sX;
2155
- const dy = mY - sY;
2156
- const d = Math.sqrt(dx * dx + dy * dy);
2157
- if (d < minLabelDist) {
2158
- minLabelDist = d;
2159
- closestLabel = item;
3803
+ const isEditMode = currentConfig?.editable ?? false;
3804
+ function pickLabel(threshold) {
3805
+ let closest = null;
3806
+ let minDist = threshold;
3807
+ for (const item of dynamicLabels) {
3808
+ if (!item.obj.visible) continue;
3809
+ if (isNodeFiltered(item.node)) continue;
3810
+ const labelMat = item.obj.material;
3811
+ if ((labelMat?.uniforms?.uAlpha?.value ?? 0) < 0.1) continue;
3812
+ const pWorld = item.obj.position;
3813
+ const pProj = smartProjectJS(pWorld);
3814
+ if (currentProjection.isClipped(pProj.z)) continue;
3815
+ const xNDC = pProj.x * uScale / uAspect;
3816
+ const yNDC = pProj.y * uScale;
3817
+ const sX = (xNDC * 0.5 + 0.5) * w;
3818
+ const sY = (-yNDC * 0.5 + 0.5) * h;
3819
+ const d = Math.sqrt((mX - sX) ** 2 + (mY - sY) ** 2);
3820
+ if (d < minDist) {
3821
+ minDist = d;
3822
+ closest = item;
3823
+ }
3824
+ }
3825
+ return closest;
3826
+ }
3827
+ if (isEditMode) {
3828
+ if (starPoints) {
3829
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
3830
+ raycaster.ray.origin.set(0, 0, 0);
3831
+ raycaster.ray.direction.copy(worldDir);
3832
+ raycaster.params.Points.threshold = 65 * (state.fov / 60);
3833
+ const hits = raycaster.intersectObject(starPoints, false);
3834
+ const pointHit = hits[0];
3835
+ if (pointHit && pointHit.index !== void 0) {
3836
+ const id = starIndexToId[pointHit.index];
3837
+ if (id) {
3838
+ const node = nodeById.get(id);
3839
+ if (node && !isNodeFiltered(node)) {
3840
+ const attr = starPoints.geometry.attributes.position;
3841
+ const starPos = new THREE6__namespace.Vector3(attr.getX(pointHit.index), attr.getY(pointHit.index), attr.getZ(pointHit.index));
3842
+ return { type: "star", node, index: pointHit.index, point: starPos, object: void 0 };
3843
+ }
3844
+ }
3845
+ }
3846
+ }
3847
+ const editLabel = pickLabel(isTouchDevice ? 48 : 32);
3848
+ if (editLabel) {
3849
+ return { type: "label", node: editLabel.node, object: editLabel.obj, point: editLabel.obj.position.clone(), index: void 0 };
2160
3850
  }
3851
+ return void 0;
2161
3852
  }
3853
+ const closestLabel = pickLabel(isTouchDevice ? 48 : 40);
2162
3854
  if (closestLabel) {
2163
3855
  return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
2164
3856
  }
2165
3857
  let closestConst = null;
2166
3858
  let minConstDist = Infinity;
3859
+ const artWorldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
3860
+ raycaster.ray.origin.set(0, 0, 0);
3861
+ raycaster.ray.direction.copy(artWorldDir);
2167
3862
  for (const item of constellationLayer.getItems()) {
2168
3863
  if (!item.mesh.visible) continue;
2169
- const pWorld = item.mesh.position;
2170
- const pProj = smartProjectJS(pWorld);
2171
- if (currentProjection.isClipped(pProj.z)) continue;
2172
- const uniforms = item.material.uniforms;
2173
- if (!uniforms || !uniforms.uSize) continue;
2174
- const uSize = uniforms.uSize.value;
2175
- const uImgAspect = uniforms.uImgAspect.value;
2176
- const uImgRotation = uniforms.uImgRotation.value;
2177
- const dist = pWorld.length();
2178
- if (dist < 1e-3) continue;
2179
- const scale = uSize / dist * uScale;
2180
- const halfH_px = scale / 2 * (h / 2);
2181
- const halfW_px = halfH_px * uImgAspect;
2182
- const xNDC = pProj.x * uScale / uAspect;
2183
- const yNDC = pProj.y * uScale;
2184
- const sX = (xNDC * 0.5 + 0.5) * w;
2185
- const sY = (-yNDC * 0.5 + 0.5) * h;
2186
- const dx = mX - sX;
2187
- const dy = mY - sY;
2188
- const dy_cart = -dy;
2189
- const cr = Math.cos(-uImgRotation);
2190
- const sr = Math.sin(-uImgRotation);
2191
- const localX = dx * cr - dy_cart * sr;
2192
- const localY = dx * sr + dy_cart * cr;
2193
- if (Math.abs(localX) < halfW_px * 1.2 && Math.abs(localY) < halfH_px * 1.2) {
2194
- const d = Math.sqrt(dx * dx + dy * dy);
3864
+ const hits = raycaster.intersectObject(item.mesh, false);
3865
+ if (hits.length > 0) {
3866
+ const d = hits[0].distance;
2195
3867
  if (!closestConst || d < minConstDist) {
2196
3868
  minConstDist = d;
2197
3869
  closestConst = item;
@@ -2204,7 +3876,7 @@ function createEngine({
2204
3876
  label: closestConst.config.title,
2205
3877
  level: -1
2206
3878
  };
2207
- return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.mesh.position.clone(), index: void 0 };
3879
+ return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.center.clone(), index: void 0 };
2208
3880
  }
2209
3881
  if (starPoints) {
2210
3882
  const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
@@ -2227,20 +3899,65 @@ function createEngine({
2227
3899
  isMouseInWindow = false;
2228
3900
  edgeHoverStart = 0;
2229
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
+ }
2230
3933
  function onMouseDown(e) {
2231
3934
  state.lastMouseX = e.clientX;
2232
3935
  state.lastMouseY = e.clientY;
2233
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
+ }
2234
3952
  const hit = pick(e);
2235
- if (hit) {
3953
+ if (hit && (hit.type === "label" || hit.type === "constellation")) {
2236
3954
  state.dragMode = "node";
2237
3955
  state.draggedNodeId = hit.node.id;
2238
3956
  state.draggedDist = hit.point.length();
2239
- document.body.style.cursor = "crosshair";
2240
- if (hit.type === "star") {
2241
- state.draggedStarIndex = hit.index ?? -1;
2242
- state.draggedGroup = null;
2243
- } else if (hit.type === "label") {
3957
+ state.draggedStarIndex = -1;
3958
+ state.velocityX = 0;
3959
+ state.velocityY = 0;
3960
+ if (hit.type === "label") {
2244
3961
  const bookId = hit.node.id;
2245
3962
  const children = [];
2246
3963
  if (starPoints && starPoints.geometry.attributes.position) {
@@ -2250,17 +3967,26 @@ function createEngine({
2250
3967
  if (starId) {
2251
3968
  const starNode = nodeById.get(starId);
2252
3969
  if (starNode && starNode.parent === bookId) {
2253
- children.push({ index: i, initialPos: new THREE5__namespace.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
3970
+ children.push({ index: i, initialPos: new THREE6__namespace.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
2254
3971
  }
2255
3972
  }
2256
3973
  }
2257
3974
  }
2258
- state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
2259
- state.draggedStarIndex = -1;
2260
- } else if (hit.type === "constellation") {
2261
- state.draggedGroup = null;
2262
- 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: [] };
2263
3988
  }
3989
+ return;
2264
3990
  }
2265
3991
  return;
2266
3992
  }
@@ -2287,6 +4013,8 @@ function createEngine({
2287
4013
  const attr = starPoints.geometry.attributes.position;
2288
4014
  attr.setXYZ(idx, newPos.x, newPos.y, newPos.z);
2289
4015
  attr.needsUpdate = true;
4016
+ const starId = starIndexToId[idx];
4017
+ if (starId) state.tempArrangement[starId] = { position: [newPos.x, newPos.y, newPos.z] };
2290
4018
  } else if (state.draggedGroup && state.draggedNodeId) {
2291
4019
  const group = state.draggedGroup;
2292
4020
  const item = dynamicLabels.find((l) => l.node.id === state.draggedNodeId);
@@ -2296,16 +4024,19 @@ function createEngine({
2296
4024
  } else if (state.draggedNodeId) {
2297
4025
  const cItem = constellationLayer.getItems().find((c) => c.config.id === state.draggedNodeId);
2298
4026
  if (cItem) {
2299
- cItem.mesh.position.copy(newPos);
2300
- state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
4027
+ const vS = group.labelInitialPos.clone().normalize();
4028
+ const vE = newPos.clone().normalize();
4029
+ cItem.mesh.quaternion.setFromUnitVectors(vS, vE);
4030
+ cItem.center.copy(newPos);
4031
+ state.tempArrangement[state.draggedNodeId] = { center: [newPos.x, newPos.y, newPos.z] };
2301
4032
  }
2302
4033
  }
2303
4034
  const vStart = group.labelInitialPos.clone().normalize();
2304
4035
  const vEnd = newPos.clone().normalize();
2305
- const q = new THREE5__namespace.Quaternion().setFromUnitVectors(vStart, vEnd);
4036
+ const q = new THREE6__namespace.Quaternion().setFromUnitVectors(vStart, vEnd);
2306
4037
  if (starPoints && group.children.length > 0) {
2307
4038
  const attr = starPoints.geometry.attributes.position;
2308
- const tempVec = new THREE5__namespace.Vector3();
4039
+ const tempVec = new THREE6__namespace.Vector3();
2309
4040
  for (const child of group.children) {
2310
4041
  tempVec.copy(child.initialPos).applyQuaternion(q);
2311
4042
  attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
@@ -2316,6 +4047,20 @@ function createEngine({
2316
4047
  }
2317
4048
  attr.needsUpdate = true;
2318
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
+ }
2319
4064
  }
2320
4065
  } else if (state.dragMode === "camera") {
2321
4066
  const deltaX = e.clientX - state.lastMouseX;
@@ -2323,13 +4068,15 @@ function createEngine({
2323
4068
  state.lastMouseX = e.clientX;
2324
4069
  state.lastMouseY = e.clientY;
2325
4070
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2326
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2327
- const latFactor = 1 - rotLock * rotLock;
2328
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2329
- 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;
2330
4077
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2331
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2332
- 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;
2333
4080
  state.lon = state.targetLon;
2334
4081
  state.lat = state.targetLat;
2335
4082
  } else {
@@ -2341,7 +4088,7 @@ function createEngine({
2341
4088
  if (res) {
2342
4089
  hoverLabelMat.uniforms.uMap.value = res.tex;
2343
4090
  const baseScale = 0.03;
2344
- const size = new THREE5__namespace.Vector2(baseScale * res.aspect, baseScale);
4091
+ const size = new THREE6__namespace.Vector2(baseScale * res.aspect, baseScale);
2345
4092
  hoverLabelMat.uniforms.uSize.value = size;
2346
4093
  hoverLabelMesh.scale.set(size.x, size.y, 1);
2347
4094
  }
@@ -2359,7 +4106,7 @@ function createEngine({
2359
4106
  handlers.onHover?.(hit?.node);
2360
4107
  constellationLayer.setHovered(hit?.node.id ?? null);
2361
4108
  }
2362
- document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
4109
+ document.body.style.cursor = hit ? currentConfig?.editable && hit.type === "star" ? "grab" : "pointer" : "default";
2363
4110
  }
2364
4111
  }
2365
4112
  function onMouseUp(e) {
@@ -2383,10 +4130,12 @@ function createEngine({
2383
4130
  if (hit) {
2384
4131
  handlers.onSelect?.(hit.node);
2385
4132
  constellationLayer.setFocused(hit.node.id);
4133
+ focusedNodeId = hit.node.id;
2386
4134
  if (hit.node.level === 2) setFocusedBook(hit.node.id);
2387
4135
  else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2388
4136
  } else {
2389
4137
  setFocusedBook(null);
4138
+ focusedNodeId = null;
2390
4139
  }
2391
4140
  }
2392
4141
  } else {
@@ -2394,10 +4143,12 @@ function createEngine({
2394
4143
  if (hit) {
2395
4144
  handlers.onSelect?.(hit.node);
2396
4145
  constellationLayer.setFocused(hit.node.id);
4146
+ focusedNodeId = hit.node.id;
2397
4147
  if (hit.node.level === 2) setFocusedBook(hit.node.id);
2398
4148
  else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2399
4149
  } else {
2400
4150
  setFocusedBook(null);
4151
+ focusedNodeId = null;
2401
4152
  }
2402
4153
  }
2403
4154
  }
@@ -2407,44 +4158,55 @@ function createEngine({
2407
4158
  const aspect = container.clientWidth / container.clientHeight;
2408
4159
  renderer.domElement.getBoundingClientRect();
2409
4160
  const vBefore = getMouseViewVector(state.fov, aspect);
2410
- 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;
2411
4167
  state.fov += e.deltaY * zoomSpeed;
2412
4168
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2413
4169
  handlers.onFovChange?.(state.fov);
2414
4170
  updateUniforms();
2415
4171
  const vAfter = getMouseViewVector(state.fov, aspect);
2416
- const quaternion = new THREE5__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
2417
- const dampStartFov = 40;
2418
- const dampEndFov = 120;
4172
+ const quaternion = new THREE6__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
4173
+ const dampStartFov = 32;
4174
+ const dampEndFov = 110;
2419
4175
  let spinAmount = 1;
2420
4176
  if (state.fov > dampStartFov) {
2421
4177
  const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2422
- spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
4178
+ spinAmount = 1 - Math.pow(t, 1.35) * 0.92;
2423
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));
2424
4185
  if (spinAmount < 0.999) {
2425
- const identityQuat = new THREE5__namespace.Quaternion();
4186
+ const identityQuat = new THREE6__namespace.Quaternion();
2426
4187
  quaternion.slerp(identityQuat, 1 - spinAmount);
2427
4188
  }
2428
4189
  const y = Math.sin(state.lat);
2429
4190
  const r = Math.cos(state.lat);
2430
4191
  const x = r * Math.sin(state.lon);
2431
4192
  const z = -r * Math.cos(state.lon);
2432
- const currentLook = new THREE5__namespace.Vector3(x, y, z);
4193
+ const currentLook = new THREE6__namespace.Vector3(x, y, z);
2433
4194
  const camForward = currentLook.clone().normalize();
2434
4195
  const camUp = camera.up.clone();
2435
- const camRight = new THREE5__namespace.Vector3().crossVectors(camForward, camUp).normalize();
2436
- const camUpOrtho = new THREE5__namespace.Vector3().crossVectors(camRight, camForward).normalize();
2437
- const mat = new THREE5__namespace.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
2438
- const qOld = new THREE5__namespace.Quaternion().setFromRotationMatrix(mat);
4196
+ const camRight = new THREE6__namespace.Vector3().crossVectors(camForward, camUp).normalize();
4197
+ const camUpOrtho = new THREE6__namespace.Vector3().crossVectors(camRight, camForward).normalize();
4198
+ const mat = new THREE6__namespace.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
4199
+ const qOld = new THREE6__namespace.Quaternion().setFromRotationMatrix(mat);
2439
4200
  const qNew = qOld.clone().multiply(quaternion);
2440
- const newForward = new THREE5__namespace.Vector3(0, 0, -1).applyQuaternion(qNew);
4201
+ const newForward = new THREE6__namespace.Vector3(0, 0, -1).applyQuaternion(qNew);
2441
4202
  state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
2442
4203
  state.lon = Math.atan2(newForward.x, -newForward.z);
2443
- const newUp = new THREE5__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
4204
+ const newUp = new THREE6__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
2444
4205
  camera.up.copy(newUp);
2445
- if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
2446
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
2447
- 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;
2448
4210
  t = Math.max(0, Math.min(1, t));
2449
4211
  const bias = ENGINE_CONFIG.zenithStrength * t;
2450
4212
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -2536,13 +4298,15 @@ function createEngine({
2536
4298
  }
2537
4299
  }
2538
4300
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2539
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2540
- const latFactor = 1 - rotLock * rotLock;
2541
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2542
- 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;
2543
4307
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2544
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2545
- 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;
2546
4310
  state.lon = state.targetLon;
2547
4311
  state.lat = state.targetLat;
2548
4312
  } else if (touches.length === 2) {
@@ -2554,9 +4318,10 @@ function createEngine({
2554
4318
  state.fov = state.pinchStartFov / scale;
2555
4319
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2556
4320
  handlers.onFovChange?.(state.fov);
2557
- if (state.fov > prevFov && state.fov > ENGINE_CONFIG.zenithStartFov) {
2558
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
2559
- 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;
2560
4325
  t = Math.max(0, Math.min(1, t));
2561
4326
  const bias = ENGINE_CONFIG.zenithStrength * t;
2562
4327
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -2569,8 +4334,12 @@ function createEngine({
2569
4334
  state.lastMouseX = center.x;
2570
4335
  state.lastMouseY = center.y;
2571
4336
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2572
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2573
- 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;
2574
4343
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2575
4344
  state.lon = state.targetLon;
2576
4345
  state.lat = state.targetLat;
@@ -2733,7 +4502,9 @@ function createEngine({
2733
4502
  if (inZoneX || inZoneY) {
2734
4503
  if (edgeHoverStart === 0) edgeHoverStart = performance.now();
2735
4504
  if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
2736
- 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);
2737
4508
  if (mouseNDC.x < -1 + t) {
2738
4509
  const s = (-1 + t - mouseNDC.x) / t;
2739
4510
  panX = -s * s * speedBase;
@@ -2743,10 +4514,10 @@ function createEngine({
2743
4514
  }
2744
4515
  if (mouseNDC.y < -1 + t) {
2745
4516
  const s = (-1 + t - mouseNDC.y) / t;
2746
- panY = -s * s * speedBase;
4517
+ panY = -s * s * speedBase * verticalPanFactor;
2747
4518
  } else if (mouseNDC.y > 1 - t) {
2748
4519
  const s = (mouseNDC.y - (1 - t)) / t;
2749
- panY = s * s * speedBase;
4520
+ panY = s * s * speedBase * verticalPanFactor;
2750
4521
  }
2751
4522
  }
2752
4523
  } else {
@@ -2778,26 +4549,56 @@ function createEngine({
2778
4549
  state.targetLat = state.lat;
2779
4550
  } else if (!state.isDragging && !flyToActive) {
2780
4551
  state.lon += state.velocityX;
4552
+ state.velocityY *= getVerticalPanFactor(state.fov, state.lat);
2781
4553
  state.lat += state.velocityY;
2782
- 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;
2783
4557
  state.velocityX *= damping;
2784
4558
  state.velocityY *= damping;
2785
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2786
- 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;
2787
4561
  }
2788
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();
2789
4578
  const y = Math.sin(state.lat);
2790
4579
  const r = Math.cos(state.lat);
2791
4580
  const x = r * Math.sin(state.lon);
2792
4581
  const z = -r * Math.cos(state.lon);
2793
- const target = new THREE5__namespace.Vector3(x, y, z);
2794
- const idealUp = new THREE5__namespace.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
4582
+ const target = new THREE6__namespace.Vector3(x, y, z);
4583
+ const idealUp = new THREE6__namespace.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
2795
4584
  camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
2796
4585
  camera.up.normalize();
2797
4586
  camera.lookAt(target);
2798
4587
  camera.updateMatrixWorld();
2799
4588
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
4589
+ if (groundMaterial?.uniforms?.uZenithFlatten) {
4590
+ const targetFlatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6__namespace.MathUtils.smoothstep(
4591
+ state.lat,
4592
+ THREE6__namespace.MathUtils.degToRad(68),
4593
+ THREE6__namespace.MathUtils.degToRad(88)
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;
4597
+ groundMaterial.uniforms.uZenithFlatten.value = flatten;
4598
+ }
2800
4599
  updateUniforms();
4600
+ if (getSceneDebug()?.horizonDiagnostics) runHorizonDiagnostics(now);
4601
+ updateChapterLabelAnchors();
2801
4602
  const nowSec = now / 1e3;
2802
4603
  const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2803
4604
  lastTickTime = nowSec;
@@ -2805,21 +4606,44 @@ function createEngine({
2805
4606
  linesFader.update(dt);
2806
4607
  artFader.target = currentConfig?.showConstellationArt ?? false;
2807
4608
  artFader.update(dt);
2808
- constellationLayer.update(state.fov, artFader.eased > 0.01);
2809
- if (artFader.eased < 1) {
2810
- constellationLayer.setGlobalOpacity?.(artFader.eased);
2811
- }
4609
+ constellationLayer.update(state.fov, artFader.eased > 0.01, camera, dt);
4610
+ const baseArtOpacity = THREE6__namespace.MathUtils.clamp(currentConfig?.constellationBaseOpacity ?? 1, 0, 300);
4611
+ constellationLayer.setGlobalOpacity?.(artFader.eased * baseArtOpacity);
2812
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;
4618
+ if (backdropStarsMaterial?.uniforms) {
4619
+ const minGain = THREE6__namespace.MathUtils.clamp(currentConfig?.backdropWideFovGain ?? 0.42, 0, 1);
4620
+ const fovT = THREE6__namespace.MathUtils.smoothstep(state.fov, 24, 100);
4621
+ const gain = THREE6__namespace.MathUtils.lerp(1, minGain, fovT);
4622
+ backdropStarsMaterial.uniforms.uBackdropGain.value = gain;
4623
+ backdropStarsMaterial.uniforms.uBackdropEnergy.value = THREE6__namespace.MathUtils.clamp(currentConfig?.backdropEnergy ?? 2.2, 0.2, 5);
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;
4630
+ }
4631
+ if (skyBackgroundMesh) skyBackgroundMesh.visible = currentConfig?.background !== "transparent";
2813
4632
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2814
- const DIVISION_THRESHOLD = 60;
2815
- const showDivisions = state.fov > DIVISION_THRESHOLD;
4633
+ if (moonMesh) moonMesh.visible = currentConfig?.showMoon ?? true;
4634
+ if (moonGlowMesh) moonGlowMesh.visible = currentConfig?.showMoon ?? true;
4635
+ const showSun = currentConfig?.showSunrise ?? true;
4636
+ if (sunDiscMesh) sunDiscMesh.visible = showSun;
4637
+ if (sunHaloMesh) sunHaloMesh.visible = showSun;
4638
+ if (milkyWayMesh) milkyWayMesh.visible = currentConfig?.showMilkyWay ?? true;
2816
4639
  if (constellationLines) {
2817
4640
  constellationLines.visible = linesFader.eased > 0.01;
2818
4641
  if (constellationLines.visible && constellationLines.material) {
2819
4642
  const mat = constellationLines.material;
2820
4643
  if (mat.uniforms?.color) {
2821
4644
  mat.uniforms.color.value.setHex(11193599);
2822
- mat.opacity = linesFader.eased;
4645
+ if (mat.uniforms.uReveal) mat.uniforms.uReveal.value = linesFader.eased;
4646
+ mat.opacity = 1;
2823
4647
  }
2824
4648
  }
2825
4649
  }
@@ -2830,116 +4654,35 @@ function createEngine({
2830
4654
  const screenW = rect.width;
2831
4655
  const screenH = rect.height;
2832
4656
  const aspect = screenW / screenH;
2833
- const labelsToCheck = [];
2834
- const occupied = [];
2835
- function isOverlapping(x2, y2, w, h) {
2836
- for (const r2 of occupied) {
2837
- if (x2 < r2.x + r2.w && x2 + w > r2.x && y2 < r2.y + r2.h && y2 + h > r2.y) return true;
2838
- }
2839
- return false;
2840
- }
2841
- const showBookLabels = currentConfig?.showBookLabels === true;
2842
- const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2843
- const showChapterLabels = currentConfig?.showChapterLabels === true;
2844
- const showGroupLabels = currentConfig?.showGroupLabels === true;
2845
- const showChapters = state.fov < 45;
2846
- for (const item of dynamicLabels) {
2847
- const uniforms = item.obj.material.uniforms;
2848
- const level = item.node.level;
2849
- let isEnabled = false;
2850
- if (level === 2 && showBookLabels) isEnabled = true;
2851
- else if (level === 1 && showDivisionLabels) isEnabled = true;
2852
- else if (level === 3 && showChapterLabels) isEnabled = true;
2853
- else if (level === 2.5 && showGroupLabels) isEnabled = true;
2854
- if (!isEnabled) {
2855
- uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2856
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2857
- continue;
2858
- }
2859
- const pWorld = item.obj.position;
2860
- const pProj = smartProjectJS(pWorld);
2861
- if (pProj.z > 0.2) {
2862
- uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2863
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2864
- continue;
2865
- }
2866
- if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2867
- uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2868
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2869
- continue;
2870
- }
2871
- const ndcX = pProj.x * globalUniforms.uScale.value / aspect;
2872
- const ndcY = pProj.y * globalUniforms.uScale.value;
2873
- const sX = (ndcX * 0.5 + 0.5) * screenW;
2874
- const sY = (-ndcY * 0.5 + 0.5) * screenH;
2875
- const size = uniforms.uSize.value;
2876
- const pixelH = size.y * screenH * 0.8;
2877
- const pixelW = size.x * screenH * 0.8;
2878
- labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level, ndcX, ndcY });
2879
- }
2880
- const hoverId = handlers._lastHoverId;
4657
+ const hoverId = handlers._lastHoverId ?? null;
2881
4658
  const selectedId = state.draggedNodeId;
2882
- labelsToCheck.sort((a, b) => {
2883
- const getScore = (l) => {
2884
- if (l.item.node.id === selectedId) return 10;
2885
- if (l.item.node.id === hoverId) return 9;
2886
- const level = l.level;
2887
- if (level === 2) return 5;
2888
- if (level === 1) return showDivisions ? 6 : 1;
2889
- return 0;
2890
- };
2891
- return getScore(b) - getScore(a);
4659
+ labelManager.update({
4660
+ nowMs: now,
4661
+ dt,
4662
+ fov: state.fov,
4663
+ camera,
4664
+ projectionId: currentProjection.id,
4665
+ screenW,
4666
+ screenH,
4667
+ globalScale: globalUniforms.uScale.value,
4668
+ aspect,
4669
+ hoverId,
4670
+ selectedId,
4671
+ focusedId: focusedNodeId,
4672
+ shouldFilter: !!currentFilter && filterStrength > 0.01,
4673
+ isNodeFiltered: (node) => {
4674
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
4675
+ return !!nodeToCheck && isNodeFiltered(nodeToCheck);
4676
+ },
4677
+ toggles: {
4678
+ showBookLabels: currentConfig?.showBookLabels === true,
4679
+ showDivisionLabels: currentConfig?.showDivisionLabels === true,
4680
+ showChapterLabels: currentConfig?.showChapterLabels === true,
4681
+ showGroupLabels: currentConfig?.showGroupLabels === true
4682
+ },
4683
+ config: currentConfig?.labelBehavior,
4684
+ project: smartProjectJS
2892
4685
  });
2893
- for (const l of labelsToCheck) {
2894
- let target2 = 0;
2895
- const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2896
- if (l.level === 1) {
2897
- let rot = 0;
2898
- const isWideAngle = currentProjection.id !== "perspective";
2899
- if (isWideAngle) {
2900
- const dx = l.sX - screenW / 2;
2901
- const dy = l.sY - screenH / 2;
2902
- rot = Math.atan2(-dy, -dx) - Math.PI / 2;
2903
- }
2904
- l.uniforms.uAngle.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2905
- }
2906
- if (l.level === 2) {
2907
- {
2908
- target2 = 1;
2909
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2910
- }
2911
- } else if (l.level === 1) {
2912
- if (showDivisions || isSpecial) {
2913
- const pad = -5;
2914
- if (!isOverlapping(l.sX - l.w / 2 - pad, l.sY - l.h / 2 - pad, l.w + pad * 2, l.h + pad * 2)) {
2915
- target2 = 1;
2916
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2917
- }
2918
- }
2919
- } else if (l.level === 2.5 || l.level === 3) {
2920
- if (showChapters || isSpecial) {
2921
- target2 = 1;
2922
- if (!isSpecial) {
2923
- const dist = Math.sqrt(l.ndcX * l.ndcX + l.ndcY * l.ndcY);
2924
- const focusFade = 1 - THREE5__namespace.MathUtils.smoothstep(0.4, 0.7, dist);
2925
- target2 *= focusFade;
2926
- }
2927
- }
2928
- }
2929
- if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2930
- const node = l.item.node;
2931
- if (node.level === 3) {
2932
- target2 = 0;
2933
- } else if (node.level === 2 || node.level === 2.5) {
2934
- const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2935
- if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2936
- target2 = 0;
2937
- }
2938
- }
2939
- }
2940
- l.uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2941
- l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2942
- }
2943
4686
  renderer.render(scene, camera);
2944
4687
  }
2945
4688
  function stop() {
@@ -2964,6 +4707,42 @@ function createEngine({
2964
4707
  function dispose() {
2965
4708
  stop();
2966
4709
  constellationLayer.dispose();
4710
+ if (moonMesh) {
4711
+ scene.remove(moonMesh);
4712
+ moonMesh.geometry.dispose();
4713
+ moonMesh.material.dispose();
4714
+ moonMesh = null;
4715
+ }
4716
+ if (moonGlowMesh) {
4717
+ scene.remove(moonGlowMesh);
4718
+ moonGlowMesh.geometry.dispose();
4719
+ moonGlowMesh.material.dispose();
4720
+ moonGlowMesh = null;
4721
+ }
4722
+ if (sunDiscMesh) {
4723
+ scene.remove(sunDiscMesh);
4724
+ sunDiscMesh.geometry.dispose();
4725
+ sunDiscMesh.material.dispose();
4726
+ sunDiscMesh = null;
4727
+ }
4728
+ if (sunHaloMesh) {
4729
+ scene.remove(sunHaloMesh);
4730
+ sunHaloMesh.geometry.dispose();
4731
+ sunHaloMesh.material.dispose();
4732
+ sunHaloMesh = null;
4733
+ }
4734
+ if (milkyWayMesh) {
4735
+ scene.remove(milkyWayMesh);
4736
+ milkyWayMesh.geometry.dispose();
4737
+ milkyWayMesh.material.dispose();
4738
+ milkyWayMesh = null;
4739
+ }
4740
+ if (skyBackgroundMesh) {
4741
+ scene.remove(skyBackgroundMesh);
4742
+ skyBackgroundMesh.geometry.dispose();
4743
+ skyBackgroundMesh.material.dispose();
4744
+ skyBackgroundMesh = null;
4745
+ }
2967
4746
  renderer.dispose();
2968
4747
  renderer.domElement.remove();
2969
4748
  }
@@ -2983,6 +4762,7 @@ function createEngine({
2983
4762
  function flyTo(nodeId, targetFov) {
2984
4763
  const node = nodeById.get(nodeId);
2985
4764
  if (!node) return;
4765
+ focusedNodeId = nodeId;
2986
4766
  const pos = getPosition(node).normalize();
2987
4767
  flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2988
4768
  flyToTargetLon = Math.atan2(pos.x, -pos.z);
@@ -3005,7 +4785,7 @@ function createEngine({
3005
4785
  }
3006
4786
  return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
3007
4787
  }
3008
- var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
4788
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG, HORIZON_ZOOM_CONFIG, ZOOM_REVEAL_CONFIG;
3009
4789
  var init_createEngine = __esm({
3010
4790
  "src/engine/createEngine.ts"() {
3011
4791
  init_layout();
@@ -3013,14 +4793,35 @@ var init_createEngine = __esm({
3013
4793
  init_ConstellationArtworkLayer();
3014
4794
  init_projections();
3015
4795
  init_fader();
4796
+ init_LabelManager();
3016
4797
  ENGINE_CONFIG = {
3017
4798
  minFov: 1,
3018
4799
  maxFov: 135,
3019
- defaultFov: 50,
4800
+ defaultFov: 35,
3020
4801
  dragSpeed: 125e-5,
3021
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,
3022
4810
  blendStart: 35,
3023
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,
3024
4825
  zenithStartFov: 75,
3025
4826
  zenithStrength: 0.15,
3026
4827
  horizonLockStrength: 0.05,
@@ -3043,10 +4844,34 @@ var init_createEngine = __esm({
3043
4844
  };
3044
4845
  ORDER_REVEAL_CONFIG = {
3045
4846
  globalDim: 0.85,
3046
- pulseAmplitude: 0.6,
4847
+ pulseAmplitude: 0.12,
3047
4848
  pulseDuration: 2,
3048
4849
  delayPerChapter: 0.1
3049
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
+ };
3050
4875
  }
3051
4876
  });
3052
4877
  var StarMap = react.forwardRef(
@@ -32233,7 +34058,7 @@ var RNG = class {
32233
34058
  const r = Math.sqrt(1 - y * y);
32234
34059
  const x = r * Math.cos(theta);
32235
34060
  const z = r * Math.sin(theta);
32236
- return new THREE5__namespace.Vector3(x, y, z);
34061
+ return new THREE6__namespace.Vector3(x, y, z);
32237
34062
  }
32238
34063
  };
32239
34064
  function simpleNoise3D(v, scale) {
@@ -32271,11 +34096,11 @@ function generateArrangement(bible, options = {}) {
32271
34096
  });
32272
34097
  });
32273
34098
  const bookCount = books.length;
32274
- const mwRad = THREE5__namespace.MathUtils.degToRad(opts.milkyWayAngle);
32275
- const mwNormal = new THREE5__namespace.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
34099
+ const mwRad = THREE6__namespace.MathUtils.degToRad(opts.milkyWayAngle);
34100
+ const mwNormal = new THREE6__namespace.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
32276
34101
  const anchors = [];
32277
34102
  for (let i = 0; i < bookCount; i++) {
32278
- let bestP = new THREE5__namespace.Vector3();
34103
+ let bestP = new THREE6__namespace.Vector3();
32279
34104
  let valid = false;
32280
34105
  let attempt = 0;
32281
34106
  while (!valid && attempt < 100) {
@@ -32301,7 +34126,7 @@ function generateArrangement(bible, options = {}) {
32301
34126
  arrangement[`B:${book.key}`] = { position: [anchorPos.x, anchorPos.y, anchorPos.z] };
32302
34127
  for (let c = 0; c < book.chapters; c++) {
32303
34128
  const localSpread = opts.clusterSpread * (0.8 + rng.next() * 0.4);
32304
- const offset = new THREE5__namespace.Vector3(
34129
+ const offset = new THREE6__namespace.Vector3(
32305
34130
  (rng.next() - 0.5) * 2,
32306
34131
  (rng.next() - 0.5) * 2,
32307
34132
  (rng.next() - 0.5) * 2
@@ -32322,7 +34147,7 @@ function generateArrangement(bible, options = {}) {
32322
34147
  const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
32323
34148
  const divId = `D:${book.testament}:${book.division}`;
32324
34149
  if (!divisions.has(divId)) {
32325
- divisions.set(divId, { sum: new THREE5__namespace.Vector3(), count: 0 });
34150
+ divisions.set(divId, { sum: new THREE6__namespace.Vector3(), count: 0 });
32326
34151
  }
32327
34152
  const entry = divisions.get(divId);
32328
34153
  entry.sum.add(anchorPos);