@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE5 from 'three';
1
+ import * as THREE6 from 'three';
2
2
  import { forwardRef, useRef, useImperativeHandle, useEffect } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
@@ -129,14 +129,14 @@ var init_constellations = __esm({
129
129
  });
130
130
  function lookAt(point, target, up) {
131
131
  const zAxis = target.clone().normalize();
132
- let xAxis = new THREE5.Vector3().crossVectors(up, zAxis);
132
+ let xAxis = new THREE6.Vector3().crossVectors(up, zAxis);
133
133
  if (xAxis.lengthSq() < 1e-4) {
134
- xAxis = new THREE5.Vector3().crossVectors(new THREE5.Vector3(1, 0, 0), zAxis);
134
+ xAxis = new THREE6.Vector3().crossVectors(new THREE6.Vector3(1, 0, 0), zAxis);
135
135
  }
136
136
  xAxis.normalize();
137
- const yAxis = new THREE5.Vector3().crossVectors(zAxis, xAxis).normalize();
138
- const m = new THREE5.Matrix4().makeBasis(xAxis, yAxis, zAxis);
139
- const v = new THREE5.Vector3(point.x, point.y, point.z);
137
+ const yAxis = new THREE6.Vector3().crossVectors(zAxis, xAxis).normalize();
138
+ const m = new THREE6.Matrix4().makeBasis(xAxis, yAxis, zAxis);
139
+ const v = new THREE6.Vector3(point.x, point.y, point.z);
140
140
  v.applyMatrix4(m);
141
141
  v.add(target);
142
142
  return { x: v.x, y: v.y, z: v.z };
@@ -217,7 +217,7 @@ function computeLayoutPositions(model, layout) {
217
217
  const radiusAtY = Math.sqrt(1 - y * y);
218
218
  const x = Math.cos(midAngle) * radiusAtY;
219
219
  const z = Math.sin(midAngle) * radiusAtY;
220
- const labelPos = new THREE5.Vector3(x, y, z).multiplyScalar(radius);
220
+ const labelPos = new THREE6.Vector3(x, y, z).multiplyScalar(radius);
221
221
  uDivision.meta.x = labelPos.x;
222
222
  uDivision.meta.y = labelPos.y;
223
223
  uDivision.meta.z = labelPos.z;
@@ -233,7 +233,7 @@ function computeLayoutPositions(model, layout) {
233
233
  const theta = startAngle + t * angleSpan;
234
234
  const x = Math.cos(theta) * radiusAtY;
235
235
  const z = Math.sin(theta) * radiusAtY;
236
- const bookPos = new THREE5.Vector3(x, y, z).multiplyScalar(radius);
236
+ const bookPos = new THREE6.Vector3(x, y, z).multiplyScalar(radius);
237
237
  const labelPos = bookPos.clone();
238
238
  labelPos.y += radius * 0.025;
239
239
  labelPos.setLength(radius);
@@ -244,7 +244,7 @@ function computeLayoutPositions(model, layout) {
244
244
  if (chapters.length > 0) {
245
245
  const territoryRadius = radius * 2 / Math.sqrt(books.length * 2) * 0.7;
246
246
  const localPoints = getConstellationLayout(bookKey, chapters.length, territoryRadius);
247
- const up = new THREE5.Vector3(0, 1, 0);
247
+ const up = new THREE6.Vector3(0, 1, 0);
248
248
  chapters.forEach((chap, idx) => {
249
249
  const uChap = updatedNodeMap.get(chap.id);
250
250
  const lp = localPoints[idx];
@@ -263,10 +263,10 @@ function computeLayoutPositions(model, layout) {
263
263
  testaments.forEach((t) => {
264
264
  const children = childrenMap.get(t.id) ?? [];
265
265
  if (children.length === 0) return;
266
- const centroid = new THREE5.Vector3();
266
+ const centroid = new THREE6.Vector3();
267
267
  children.forEach((c) => {
268
268
  const u = updatedNodeMap.get(c.id);
269
- centroid.add(new THREE5.Vector3(u.meta.x, u.meta.y, u.meta.z));
269
+ centroid.add(new THREE6.Vector3(u.meta.x, u.meta.y, u.meta.z));
270
270
  });
271
271
  centroid.divideScalar(children.length);
272
272
  if (centroid.length() > 0.1) {
@@ -387,7 +387,7 @@ float getMaskAlpha() {
387
387
  });
388
388
  function createSmartMaterial(params) {
389
389
  const uniforms = { ...globalUniforms, ...params.uniforms };
390
- return new THREE5.ShaderMaterial({
390
+ return new THREE6.ShaderMaterial({
391
391
  uniforms,
392
392
  vertexShader: `
393
393
  ${BLEND_CHUNK}
@@ -401,8 +401,8 @@ function createSmartMaterial(params) {
401
401
  transparent: params.transparent || false,
402
402
  depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
403
403
  depthTest: params.depthTest !== void 0 ? params.depthTest : true,
404
- side: params.side || THREE5.FrontSide,
405
- blending: params.blending || THREE5.NormalBlending
404
+ side: params.side || THREE6.FrontSide,
405
+ blending: params.blending || THREE6.NormalBlending
406
406
  });
407
407
  }
408
408
  var globalUniforms;
@@ -420,16 +420,102 @@ var init_materials = __esm({
420
420
  uAtmGlow: { value: 1 },
421
421
  uAtmDark: { value: 0.6 },
422
422
  uAtmExtinction: { value: 4 },
423
- uAtmTwinkle: { value: 0.8 },
424
- uColorHorizon: { value: new THREE5.Color(3825292) },
425
- uColorZenith: { value: new THREE5.Color(132104) }
423
+ uAtmTwinkle: { value: 0 },
424
+ uColorHorizon: { value: new THREE6.Color(3825292) },
425
+ uColorZenith: { value: new THREE6.Color(132104) }
426
426
  };
427
427
  }
428
428
  });
429
+
430
+ // src/engine/fader.ts
431
+ var Fader;
432
+ var init_fader = __esm({
433
+ "src/engine/fader.ts"() {
434
+ Fader = class {
435
+ target = false;
436
+ value = 0;
437
+ duration;
438
+ constructor(duration = 0.3) {
439
+ this.duration = duration;
440
+ }
441
+ update(dt) {
442
+ const goal = this.target ? 1 : 0;
443
+ if (this.value === goal) return;
444
+ const speed = 1 / this.duration;
445
+ const step = speed * dt;
446
+ const diff = goal - this.value;
447
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
448
+ }
449
+ /** Smoothstep-eased value for perceptually smooth transitions */
450
+ get eased() {
451
+ const v = this.value;
452
+ return v * v * (3 - 2 * v);
453
+ }
454
+ };
455
+ }
456
+ });
457
+ function buildSphereQuad(center, rightDir, upDir, halfWidth, halfHeight, domeRadius, subdivisions = 8) {
458
+ const vertsPerSide = subdivisions + 1;
459
+ const vertCount = vertsPerSide * vertsPerSide;
460
+ const positions = new Float32Array(vertCount * 3);
461
+ const uvs = new Float32Array(vertCount * 2);
462
+ const centerNorm = center.clone().normalize();
463
+ const temp = new THREE6.Vector3();
464
+ const tangent = new THREE6.Vector3();
465
+ const q = new THREE6.Quaternion();
466
+ const halfAngleX = Math.atan2(halfWidth, domeRadius);
467
+ const halfAngleY = Math.atan2(halfHeight, domeRadius);
468
+ for (let iy = 0; iy < vertsPerSide; iy++) {
469
+ for (let ix = 0; ix < vertsPerSide; ix++) {
470
+ const idx = iy * vertsPerSide + ix;
471
+ const u = ix / subdivisions;
472
+ const v = iy / subdivisions;
473
+ const angX = (u - 0.5) * 2 * halfAngleX;
474
+ const angY = (v - 0.5) * 2 * halfAngleY;
475
+ tangent.copy(rightDir).multiplyScalar(angX).addScaledVector(upDir, angY);
476
+ const angle = tangent.length();
477
+ if (angle > 1e-5) {
478
+ tangent.normalize();
479
+ q.setFromAxisAngle(tangent, angle);
480
+ temp.copy(centerNorm).applyQuaternion(q).multiplyScalar(domeRadius);
481
+ } else {
482
+ temp.copy(center);
483
+ }
484
+ positions[idx * 3 + 0] = temp.x;
485
+ positions[idx * 3 + 1] = temp.y;
486
+ positions[idx * 3 + 2] = temp.z;
487
+ uvs[idx * 2 + 0] = u;
488
+ uvs[idx * 2 + 1] = 1 - v;
489
+ }
490
+ }
491
+ const indexCount = subdivisions * subdivisions * 6;
492
+ const indices = new Uint16Array(indexCount);
493
+ let ii = 0;
494
+ for (let iy = 0; iy < subdivisions; iy++) {
495
+ for (let ix = 0; ix < subdivisions; ix++) {
496
+ const a = iy * vertsPerSide + ix;
497
+ const b = a + 1;
498
+ const c = a + vertsPerSide;
499
+ const d = c + 1;
500
+ indices[ii++] = a;
501
+ indices[ii++] = c;
502
+ indices[ii++] = b;
503
+ indices[ii++] = b;
504
+ indices[ii++] = c;
505
+ indices[ii++] = d;
506
+ }
507
+ }
508
+ const geometry = new THREE6.BufferGeometry();
509
+ geometry.setAttribute("position", new THREE6.BufferAttribute(positions, 3));
510
+ geometry.setAttribute("uv", new THREE6.BufferAttribute(uvs, 2));
511
+ geometry.setIndex(new THREE6.BufferAttribute(indices, 1));
512
+ return geometry;
513
+ }
429
514
  var ConstellationArtworkLayer;
430
515
  var init_ConstellationArtworkLayer = __esm({
431
516
  "src/engine/ConstellationArtworkLayer.ts"() {
432
517
  init_materials();
518
+ init_fader();
433
519
  ConstellationArtworkLayer = class {
434
520
  root;
435
521
  items = [];
@@ -437,27 +523,22 @@ var init_ConstellationArtworkLayer = __esm({
437
523
  hoveredId = null;
438
524
  focusedId = null;
439
525
  constructor(root) {
440
- this.textureLoader = new THREE5.TextureLoader();
526
+ this.textureLoader = new THREE6.TextureLoader();
441
527
  this.textureLoader.crossOrigin = "anonymous";
442
- this.root = new THREE5.Group();
528
+ this.root = new THREE6.Group();
443
529
  this.root.renderOrder = -1;
444
530
  root.add(this.root);
445
531
  }
446
532
  getItems() {
447
533
  return this.items;
448
534
  }
449
- setPosition(id, pos) {
450
- const item = this.items.find((i) => i.config.id === id);
451
- if (item) {
452
- item.mesh.position.copy(pos);
453
- }
454
- }
455
- load(config, getPosition) {
535
+ load(config, getPosition, getOrientationPosition) {
536
+ const getAnchorPos = getOrientationPosition ?? getPosition;
456
537
  this.clear();
457
538
  console.log(`[Constellation] Loading ${config.constellations.length} constellations from ${config.atlasBasePath}`);
458
539
  const basePath = config.atlasBasePath.replace(/\/$/, "");
459
540
  config.constellations.forEach((c) => {
460
- let center = new THREE5.Vector3();
541
+ let center = new THREE6.Vector3();
461
542
  let valid = false;
462
543
  let radius = 2e3;
463
544
  const arrPos = getPosition(c.id);
@@ -475,7 +556,7 @@ var init_ConstellationArtworkLayer = __esm({
475
556
  }
476
557
  }
477
558
  } else if (c.center) {
478
- center.set(c.center[0], c.center[1], c.center[2]);
559
+ center.set(c.center[0], c.center[1], 0);
479
560
  valid = true;
480
561
  } else if (c.anchors.length > 0) {
481
562
  const points = [];
@@ -495,100 +576,65 @@ var init_ConstellationArtworkLayer = __esm({
495
576
  }
496
577
  }
497
578
  if (!valid) return;
498
- const normal = center.clone().normalize().negate();
499
- const upVec = center.clone().normalize();
500
- let right = new THREE5.Vector3(1, 0, 0);
579
+ const centerNorm = center.clone().normalize();
580
+ let rightDir = new THREE6.Vector3();
581
+ let upDir = new THREE6.Vector3();
501
582
  if (c.anchors.length >= 2) {
502
- const p0 = getPosition(c.anchors[0]);
503
- const p1 = getPosition(c.anchors[1]);
504
- if (p0 && p1) {
505
- const diff = new THREE5.Vector3().subVectors(p1, p0);
506
- right.copy(diff).sub(upVec.clone().multiplyScalar(diff.dot(upVec))).normalize();
583
+ const p0 = getAnchorPos(c.anchors[0]);
584
+ const p1 = getAnchorPos(c.anchors[1]);
585
+ if (p0 && p1 && p0.distanceTo(p1) > 1e-3) {
586
+ const diff = new THREE6.Vector3().subVectors(p1, p0);
587
+ rightDir.copy(diff).sub(centerNorm.clone().multiplyScalar(diff.dot(centerNorm))).normalize();
588
+ upDir.crossVectors(centerNorm, rightDir).normalize();
589
+ rightDir.crossVectors(upDir, centerNorm).normalize();
590
+ } else {
591
+ this._defaultTangentFrame(centerNorm, rightDir, upDir);
507
592
  }
508
593
  } else {
509
- if (Math.abs(upVec.y) > 0.9) right.set(1, 0, 0).cross(upVec).normalize();
510
- else right.set(0, 1, 0).cross(upVec).normalize();
594
+ this._defaultTangentFrame(centerNorm, rightDir, upDir);
595
+ }
596
+ if (c.rotationDeg !== 0) {
597
+ const q = new THREE6.Quaternion().setFromAxisAngle(centerNorm, THREE6.MathUtils.degToRad(c.rotationDeg));
598
+ rightDir.applyQuaternion(q);
599
+ upDir.applyQuaternion(q);
511
600
  }
512
- const top = new THREE5.Vector3().crossVectors(upVec, right).normalize();
513
- right.crossVectors(top, upVec).normalize();
514
- new THREE5.Matrix4().makeBasis(right, top, normal);
515
- const geometry = new THREE5.PlaneGeometry(1, 1);
516
601
  let size = c.radius;
517
602
  if (size <= 1) size *= radius;
518
603
  size *= 2;
604
+ const sqrtAspect = Math.sqrt(c.aspectRatio ?? 1);
605
+ const halfWidth = size / 2 * sqrtAspect;
606
+ const halfHeight = size / 2 / sqrtAspect;
607
+ const geometry = buildSphereQuad(center, rightDir, upDir, halfWidth, halfHeight, radius, 8);
519
608
  const texPath = `${basePath}/${c.image}`;
520
- let blending = THREE5.NormalBlending;
521
- if (c.blend === "additive") blending = THREE5.AdditiveBlending;
609
+ const blending = c.blend === "additive" ? THREE6.AdditiveBlending : THREE6.NormalBlending;
522
610
  const material = createSmartMaterial({
523
611
  uniforms: {
524
612
  uMap: { value: this.textureLoader.load(texPath) },
525
- // Placeholder, updated below
526
- uOpacity: { value: c.opacity },
527
- uSize: { value: size },
528
- uImgRotation: { value: THREE5.MathUtils.degToRad(c.rotationDeg) },
529
- uImgAspect: { value: c.aspectRatio ?? 1 }
530
- // uScale, uAspect (screen) are injected by createSmartMaterial/globalUniforms
613
+ uOpacity: { value: c.opacity }
531
614
  },
532
615
  vertexShaderBody: `
533
- #ifdef GL_ES
534
- precision highp float;
535
- #endif
536
-
537
- uniform float uSize;
538
- uniform float uImgRotation;
539
- uniform float uImgAspect;
540
-
541
616
  varying vec2 vUv;
542
-
617
+ varying float vClipFade;
543
618
  void main() {
544
619
  vUv = uv;
545
-
546
- // 1. Project Center Point (Proven Method)
547
- vec4 mvCenter = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
548
- vec4 clipCenter = smartProject(mvCenter);
549
-
550
- // 2. Project "Up" Point (World Zenith)
551
- // Transform World Up (0,1,0) to View Space
552
- vec3 viewUpDir = mat3(viewMatrix) * vec3(0.0, 1.0, 0.0);
553
- // Offset center by a significant amount (1000.0) to ensure screen delta
554
- vec4 mvUp = mvCenter + vec4(viewUpDir * 1000.0, 0.0);
555
- vec4 clipUp = smartProject(mvUp);
556
-
557
- // 3. Calculate Horizon Angle
558
- vec2 screenCenter = clipCenter.xy / clipCenter.w;
559
- vec2 screenUp = clipUp.xy / clipUp.w;
560
- vec2 screenDelta = screenUp - screenCenter;
561
-
562
- float horizonAngle = 0.0;
563
- if (length(screenDelta) > 0.001) {
564
- vec2 screenDir = normalize(screenDelta);
565
- horizonAngle = atan(screenDir.y, screenDir.x) - 1.5708; // -90 deg
620
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
621
+
622
+ // Compute clip-boundary fade BEFORE smartProject
623
+ // (Stellarium culls entire constellations near the boundary;
624
+ // we fade per-vertex for smoother transitions)
625
+ vec3 viewDir = normalize(mvPosition.xyz);
626
+ float clipZ;
627
+ if (uProjectionType == 0) {
628
+ clipZ = -0.1;
629
+ } else if (uProjectionType == 1) {
630
+ clipZ = 0.1;
631
+ } else {
632
+ clipZ = mix(-0.1, 0.1, uBlend);
566
633
  }
567
-
568
- // 4. Combine with User Rotation
569
- float finalAngle = uImgRotation + horizonAngle;
570
-
571
- // 5. Billboard Offset
572
- vec2 offset = position.xy;
573
-
574
- float cr = cos(finalAngle);
575
- float sr = sin(finalAngle);
576
- vec2 rotated = vec2(
577
- offset.x * cr - offset.y * sr,
578
- offset.x * sr + offset.y * cr
579
- );
580
-
581
- rotated.x *= uImgAspect;
582
-
583
- float dist = length(mvCenter.xyz);
584
- float scale = (uSize / dist) * uScale;
585
-
586
- rotated *= scale;
587
- rotated.x /= uAspect;
588
-
589
- gl_Position = clipCenter;
590
- gl_Position.xy += rotated * clipCenter.w;
591
-
634
+ // Smooth fade over 0.3 radians before the clip threshold
635
+ vClipFade = smoothstep(clipZ, clipZ - 0.3, viewDir.z);
636
+
637
+ gl_Position = smartProject(mvPosition);
592
638
  vScreenPos = gl_Position.xy / gl_Position.w;
593
639
  }
594
640
  `,
@@ -599,30 +645,50 @@ var init_ConstellationArtworkLayer = __esm({
599
645
  uniform sampler2D uMap;
600
646
  uniform float uOpacity;
601
647
  varying vec2 vUv;
648
+ varying float vClipFade;
602
649
  void main() {
603
650
  float mask = getMaskAlpha();
604
651
  if (mask < 0.01) discard;
605
652
  vec4 tex = texture2D(uMap, vUv);
606
- gl_FragColor = vec4(tex.rgb, tex.a * uOpacity * mask);
653
+
654
+ // Apply a slight blue tinge to the artwork
655
+ vec3 color = tex.rgb * vec3(0.8, 0.9, 1.0);
656
+
657
+ // vClipFade smoothly hides vertices near the projection
658
+ // clip boundary, preventing mesh distortion from escape positions
659
+ gl_FragColor = vec4(color, tex.a * uOpacity * mask * vClipFade);
607
660
  }
608
661
  `,
609
662
  transparent: true,
610
663
  depthWrite: false,
611
664
  depthTest: true,
612
665
  blending,
613
- side: THREE5.DoubleSide
666
+ side: THREE6.DoubleSide
614
667
  });
615
668
  material.uniforms.uMap.value = this.textureLoader.load(
616
669
  texPath,
617
670
  (tex) => {
618
- tex.minFilter = THREE5.LinearFilter;
619
- tex.magFilter = THREE5.LinearFilter;
671
+ tex.minFilter = THREE6.LinearFilter;
672
+ tex.magFilter = THREE6.LinearFilter;
620
673
  tex.generateMipmaps = false;
621
674
  tex.needsUpdate = true;
622
675
  if (c.aspectRatio === void 0 && tex.image.width && tex.image.height) {
623
676
  const natAspect = tex.image.width / tex.image.height;
624
- material.uniforms.uImgAspect.value = natAspect;
677
+ const sqrtNatAspect = Math.sqrt(natAspect);
678
+ const newHalfWidth = size / 2 * sqrtNatAspect;
679
+ const newHalfHeight = size / 2 / sqrtNatAspect;
680
+ const newGeometry = buildSphereQuad(center, rightDir, upDir, newHalfWidth, newHalfHeight, radius, 8);
681
+ const item2 = this.items.find((i) => i.config.id === c.id);
682
+ if (item2) {
683
+ item2.mesh.geometry.dispose();
684
+ item2.mesh.geometry = newGeometry;
685
+ item2.halfWidth = newHalfWidth;
686
+ item2.halfHeight = newHalfHeight;
687
+ item2.imageLoadedFader.target = true;
688
+ }
625
689
  }
690
+ const item = this.items.find((i) => i.config.id === c.id);
691
+ if (item) item.imageLoadedFader.target = true;
626
692
  console.log(`[Constellation] Loaded: ${c.id} (${tex.image.width}x${tex.image.height})`);
627
693
  },
628
694
  (progress) => {
@@ -635,23 +701,55 @@ var init_ConstellationArtworkLayer = __esm({
635
701
  material.polygonOffset = true;
636
702
  material.polygonOffsetFactor = -c.zBias;
637
703
  }
638
- const mesh = new THREE5.Mesh(geometry, material);
704
+ const mesh = new THREE6.Mesh(geometry, material);
639
705
  mesh.frustumCulled = false;
640
706
  mesh.userData = { id: c.id, type: "constellation" };
641
- mesh.position.copy(center);
642
707
  this.root.add(mesh);
643
- this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
708
+ this.items.push({
709
+ config: c,
710
+ mesh,
711
+ material,
712
+ baseOpacity: c.opacity,
713
+ center: center.clone(),
714
+ rightDir: rightDir.clone(),
715
+ upDir: upDir.clone(),
716
+ halfWidth,
717
+ halfHeight,
718
+ domeRadius: radius,
719
+ visibleFader: new Fader(0.35),
720
+ imageLoadedFader: new Fader(0.5)
721
+ });
644
722
  });
645
723
  }
724
+ _defaultTangentFrame(centerNorm, rightDir, upDir) {
725
+ const worldUp = new THREE6.Vector3(0, 1, 0);
726
+ if (Math.abs(centerNorm.dot(worldUp)) > 0.99) {
727
+ rightDir.crossVectors(new THREE6.Vector3(1, 0, 0), centerNorm).normalize();
728
+ } else {
729
+ rightDir.crossVectors(worldUp, centerNorm).normalize();
730
+ }
731
+ upDir.crossVectors(centerNorm, rightDir).normalize();
732
+ rightDir.crossVectors(upDir, centerNorm).normalize();
733
+ }
646
734
  _globalOpacity = 1;
647
735
  setGlobalOpacity(v) {
648
736
  this._globalOpacity = v;
649
737
  }
650
- update(fov, showArt) {
738
+ /**
739
+ * Update visibility and opacity.
740
+ * Accepts an optional camera for Stellarium-style visibility culling:
741
+ * constellations whose center is near or past the projection clip
742
+ * boundary are hidden to prevent mesh distortion from escape positions.
743
+ */
744
+ update(fov, showArt, camera, dt = 0.016) {
651
745
  this.root.visible = showArt;
652
746
  if (!showArt) {
653
747
  return;
654
748
  }
749
+ let cameraForward = null;
750
+ if (camera) {
751
+ cameraForward = new THREE6.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
752
+ }
655
753
  for (const item of this.items) {
656
754
  const { fade } = item.config;
657
755
  let opacity = fade.maxOpacity;
@@ -661,10 +759,30 @@ var init_ConstellationArtworkLayer = __esm({
661
759
  opacity = fade.minOpacity;
662
760
  } else {
663
761
  const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
664
- opacity = THREE5.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
762
+ opacity = THREE6.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
665
763
  }
666
- opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
764
+ const halfAngleX = Math.atan2(item.halfWidth, item.domeRadius);
765
+ const halfAngleY = Math.atan2(item.halfHeight, item.domeRadius);
766
+ const diameterDeg = Math.max(halfAngleX, halfAngleY) * 2 * THREE6.MathUtils.RAD2DEG;
767
+ const zoomFade = THREE6.MathUtils.smoothstep(fov, diameterDeg / 5, diameterDeg / 2);
768
+ opacity *= zoomFade;
769
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity * item.baseOpacity;
770
+ if (cameraForward) {
771
+ const centerDir = item.center.clone().normalize();
772
+ const dot = cameraForward.dot(centerDir);
773
+ const angle = Math.acos(THREE6.MathUtils.clamp(dot, -1, 1));
774
+ const angularRadius = Math.max(halfAngleX, halfAngleY);
775
+ const margin = THREE6.MathUtils.degToRad(8);
776
+ item.visibleFader.target = angle <= Math.PI * 0.5 + angularRadius + margin;
777
+ } else {
778
+ item.visibleFader.target = true;
779
+ }
780
+ item.visibleFader.update(dt);
781
+ opacity *= item.visibleFader.eased;
782
+ item.imageLoadedFader.update(dt);
783
+ opacity *= item.imageLoadedFader.eased;
667
784
  item.material.uniforms.uOpacity.value = opacity;
785
+ item.mesh.visible = opacity > 1e-3;
668
786
  }
669
787
  }
670
788
  setHovered(id) {
@@ -761,6 +879,7 @@ var init_projections = __esm({
761
879
  blendEnd;
762
880
  /** Current blend factor, updated via setFov() */
763
881
  blend = 0;
882
+ blendOverride = null;
764
883
  constructor(blendStart = 40, blendEnd = 100) {
765
884
  this.blendStart = blendStart;
766
885
  this.blendEnd = blendEnd;
@@ -779,14 +898,22 @@ var init_projections = __esm({
779
898
  this.blend = t * t * (3 - 2 * t);
780
899
  }
781
900
  getBlend() {
782
- return this.blend;
901
+ return this.blendOverride ?? this.blend;
902
+ }
903
+ setBlendOverride(value) {
904
+ if (value === null || value === void 0 || Number.isNaN(value)) {
905
+ this.blendOverride = null;
906
+ return;
907
+ }
908
+ this.blendOverride = Math.max(0, Math.min(1, value));
783
909
  }
784
910
  forward(dir) {
785
- if (this.blend > 0.5 && dir.z > 0.4) return null;
786
- if (this.blend < 0.1 && dir.z > -0.1) return null;
911
+ const b = this.getBlend();
912
+ if (b > 0.5 && dir.z > 0.4) return null;
913
+ if (b < 0.1 && dir.z > -0.1) return null;
787
914
  const kLinear = 1 / Math.max(0.01, -dir.z);
788
915
  const kStereo = 2 / (1 - dir.z);
789
- const k = kLinear * (1 - this.blend) + kStereo * this.blend;
916
+ const k = kLinear * (1 - b) + kStereo * b;
790
917
  return { x: k * dir.x, y: k * dir.y, z: dir.z };
791
918
  }
792
919
  inverse(uvX, uvY, fovRad) {
@@ -795,7 +922,8 @@ var init_projections = __esm({
795
922
  const thetaLin = Math.atan(r * halfHeightLin);
796
923
  const halfHeightStereo = 2 * Math.tan(fovRad / 4);
797
924
  const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
798
- const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
925
+ const b = this.getBlend();
926
+ const theta = thetaLin * (1 - b) + thetaStereo * b;
799
927
  const phi = Math.atan2(uvY, uvX);
800
928
  const sinT = Math.sin(theta);
801
929
  return {
@@ -807,11 +935,13 @@ var init_projections = __esm({
807
935
  getScale(fovRad) {
808
936
  const scaleLinear = 1 / Math.tan(fovRad / 2);
809
937
  const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
810
- return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
938
+ const b = this.getBlend();
939
+ return scaleLinear * (1 - b) + scaleStereo * b;
811
940
  }
812
941
  isClipped(dirZ) {
813
- if (this.blend > 0.5) return dirZ > 0.4;
814
- if (this.blend < 0.1) return dirZ > -0.1;
942
+ const b = this.getBlend();
943
+ if (b > 0.5) return dirZ > 0.4;
944
+ if (b < 0.1) return dirZ > -0.1;
815
945
  return false;
816
946
  }
817
947
  };
@@ -821,30 +951,366 @@ var init_projections = __esm({
821
951
  };
822
952
  }
823
953
  });
824
-
825
- // src/engine/fader.ts
826
- var Fader;
827
- var init_fader = __esm({
828
- "src/engine/fader.ts"() {
829
- Fader = class {
830
- target = false;
831
- value = 0;
832
- duration;
833
- constructor(duration = 0.3) {
834
- this.duration = duration;
954
+ function levelToClass(level) {
955
+ if (level === 1) return "division";
956
+ if (level === 2) return "book";
957
+ if (level === 2.5) return "group";
958
+ return "chapter";
959
+ }
960
+ function isClassEnabled(classKey, toggles) {
961
+ if (classKey === "division") return toggles.showDivisionLabels;
962
+ if (classKey === "book") return toggles.showBookLabels;
963
+ if (classKey === "group") return toggles.showGroupLabels;
964
+ return toggles.showChapterLabels;
965
+ }
966
+ function lerp(a, b, t) {
967
+ return a + (b - a) * t;
968
+ }
969
+ function resolveLabelBehavior(config) {
970
+ const classes = { ...DEFAULT_LABEL_BEHAVIOR.classes };
971
+ const mergeClass = (key, source) => {
972
+ const base = DEFAULT_LABEL_BEHAVIOR.classes[key];
973
+ return {
974
+ minFov: source?.minFov ?? base.minFov,
975
+ maxFov: source?.maxFov ?? base.maxFov,
976
+ priority: source?.priority ?? base.priority,
977
+ mode: source?.mode ?? base.mode,
978
+ maxOverlapPx: source?.maxOverlapPx ?? base.maxOverlapPx,
979
+ radialFadeStart: source?.radialFadeStart ?? base.radialFadeStart,
980
+ radialFadeEnd: source?.radialFadeEnd ?? base.radialFadeEnd,
981
+ fadeDuration: source?.fadeDuration ?? base.fadeDuration
982
+ };
983
+ };
984
+ classes.division = mergeClass("division", config?.classes?.division);
985
+ classes.book = mergeClass("book", config?.classes?.book);
986
+ classes.group = mergeClass("group", config?.classes?.group);
987
+ classes.chapter = mergeClass("chapter", config?.classes?.chapter);
988
+ return {
989
+ hideBackFacing: config?.hideBackFacing ?? DEFAULT_LABEL_BEHAVIOR.hideBackFacing,
990
+ overlapPaddingPx: config?.overlapPaddingPx ?? DEFAULT_LABEL_BEHAVIOR.overlapPaddingPx,
991
+ reappearDelayMs: config?.reappearDelayMs ?? DEFAULT_LABEL_BEHAVIOR.reappearDelayMs,
992
+ classes
993
+ };
994
+ }
995
+ function boundsOverlapDepth(a, b) {
996
+ const ix0 = Math.max(a.x, b.x);
997
+ const iy0 = Math.max(a.y, b.y);
998
+ const ix1 = Math.min(a.x + a.w, b.x + b.w);
999
+ const iy1 = Math.min(a.y + a.h, b.y + b.h);
1000
+ if (ix1 <= ix0 || iy1 <= iy0) return 0;
1001
+ return Math.min(ix1 - ix0, iy1 - iy0);
1002
+ }
1003
+ function boundsDistPoint(rect, px, py) {
1004
+ const cx = rect.x + rect.w * 0.5;
1005
+ const cy = rect.y + rect.h * 0.5;
1006
+ const dx = Math.max(Math.abs(px - cx) - rect.w * 0.5, 0);
1007
+ const dy = Math.max(Math.abs(py - cy) - rect.h * 0.5, 0);
1008
+ return Math.sqrt(dx * dx + dy * dy);
1009
+ }
1010
+ function getLabelUniforms(obj) {
1011
+ const material = obj.material;
1012
+ if (!(material instanceof THREE6.ShaderMaterial) || !material.uniforms) return null;
1013
+ const uniforms = material.uniforms;
1014
+ const uSize = uniforms.uSize;
1015
+ const uAlpha = uniforms.uAlpha;
1016
+ const uAngle = uniforms.uAngle;
1017
+ if (!uSize || !(uSize.value instanceof THREE6.Vector2) || !uAlpha || typeof uAlpha.value !== "number") {
1018
+ return null;
1019
+ }
1020
+ return {
1021
+ uSize: { value: uSize.value },
1022
+ uAlpha: { value: uAlpha.value },
1023
+ uAngle: uAngle && typeof uAngle.value === "number" ? { value: uAngle.value } : void 0
1024
+ };
1025
+ }
1026
+ function applyUniformAlpha(obj, alpha, angle) {
1027
+ const material = obj.material;
1028
+ if (!(material instanceof THREE6.ShaderMaterial) || !material.uniforms) return;
1029
+ const uniforms = material.uniforms;
1030
+ if (uniforms.uAlpha && typeof uniforms.uAlpha.value === "number") {
1031
+ uniforms.uAlpha.value = alpha;
1032
+ }
1033
+ }
1034
+ var DEFAULT_LABEL_BEHAVIOR, LabelManager;
1035
+ var init_LabelManager = __esm({
1036
+ "src/engine/LabelManager.ts"() {
1037
+ init_fader();
1038
+ DEFAULT_LABEL_BEHAVIOR = {
1039
+ hideBackFacing: true,
1040
+ overlapPaddingPx: 2,
1041
+ reappearDelayMs: 60,
1042
+ classes: {
1043
+ division: {
1044
+ minFov: 55,
1045
+ maxFov: 180,
1046
+ priority: 70,
1047
+ mode: "floating",
1048
+ maxOverlapPx: 10,
1049
+ radialFadeStart: 1,
1050
+ radialFadeEnd: 1.2,
1051
+ fadeDuration: 0.28
1052
+ },
1053
+ book: {
1054
+ minFov: 0,
1055
+ maxFov: 22,
1056
+ priority: 60,
1057
+ mode: "pinned",
1058
+ maxOverlapPx: 999,
1059
+ radialFadeStart: 1,
1060
+ radialFadeEnd: 1.2,
1061
+ fadeDuration: 0.22
1062
+ },
1063
+ group: {
1064
+ minFov: 0,
1065
+ maxFov: 22,
1066
+ priority: 42,
1067
+ mode: "pinned",
1068
+ maxOverlapPx: 999,
1069
+ radialFadeStart: 1,
1070
+ radialFadeEnd: 1.2,
1071
+ fadeDuration: 0.22
1072
+ },
1073
+ chapter: {
1074
+ minFov: 0,
1075
+ maxFov: 22,
1076
+ priority: 30,
1077
+ mode: "pinned",
1078
+ maxOverlapPx: 999,
1079
+ radialFadeStart: 0.55,
1080
+ radialFadeEnd: 0.95,
1081
+ fadeDuration: 0.16
1082
+ }
835
1083
  }
836
- update(dt) {
837
- const goal = this.target ? 1 : 0;
838
- if (this.value === goal) return;
839
- const speed = 1 / this.duration;
840
- const step = speed * dt;
841
- const diff = goal - this.value;
842
- this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
1084
+ };
1085
+ LabelManager = class {
1086
+ records = /* @__PURE__ */ new Map();
1087
+ setLabels(labels) {
1088
+ const activeIds = /* @__PURE__ */ new Set();
1089
+ for (const label of labels) {
1090
+ activeIds.add(label.node.id);
1091
+ const existing = this.records.get(label.node.id);
1092
+ if (existing) {
1093
+ existing.label = label;
1094
+ existing.classKey = levelToClass(label.node.level);
1095
+ continue;
1096
+ }
1097
+ this.records.set(label.node.id, {
1098
+ id: label.node.id,
1099
+ label,
1100
+ fader: new Fader(0.2),
1101
+ classKey: levelToClass(label.node.level),
1102
+ lastRejectedAtMs: 0,
1103
+ lastAccepted: false,
1104
+ targetAlpha: 0
1105
+ });
1106
+ }
1107
+ for (const [id] of this.records) {
1108
+ if (!activeIds.has(id)) {
1109
+ this.records.delete(id);
1110
+ }
1111
+ }
843
1112
  }
844
- /** Smoothstep-eased value for perceptually smooth transitions */
845
- get eased() {
846
- const v = this.value;
847
- return v * v * (3 - 2 * v);
1113
+ clear() {
1114
+ this.records.clear();
1115
+ }
1116
+ update(ctx) {
1117
+ const behavior = resolveLabelBehavior(ctx.config);
1118
+ const candidates = [];
1119
+ const cameraForward = new THREE6.Vector3(0, 0, -1).applyQuaternion(ctx.camera.quaternion);
1120
+ for (const record of this.records.values()) {
1121
+ const classBehavior = behavior.classes[record.classKey];
1122
+ record.fader.duration = classBehavior.fadeDuration;
1123
+ const isEnabled = isClassEnabled(record.classKey, ctx.toggles);
1124
+ const isSpecial = record.id === ctx.selectedId || record.id === ctx.hoverId || record.id === ctx.focusedId;
1125
+ let targetAlpha = 0;
1126
+ let angleTarget;
1127
+ if (isEnabled) {
1128
+ const maxFov = classBehavior.maxFov + (record.label.maxFovBias ?? 0);
1129
+ const inFovRange = ctx.fov >= classBehavior.minFov && ctx.fov <= maxFov;
1130
+ if (inFovRange || isSpecial) {
1131
+ const pWorld = record.label.obj.position;
1132
+ const pProj = ctx.project(pWorld);
1133
+ const frontVisible = pProj.z <= 0.2;
1134
+ let backFacing = false;
1135
+ if (behavior.hideBackFacing) {
1136
+ const worldDir = pWorld.clone().normalize();
1137
+ backFacing = worldDir.dot(cameraForward) < -0.2;
1138
+ }
1139
+ if (frontVisible && !backFacing) {
1140
+ const ndcX = pProj.x * ctx.globalScale / ctx.aspect;
1141
+ const ndcY = pProj.y * ctx.globalScale;
1142
+ const sX = (ndcX * 0.5 + 0.5) * ctx.screenW;
1143
+ const sY = (-ndcY * 0.5 + 0.5) * ctx.screenH;
1144
+ const uniforms = getLabelUniforms(record.label.obj);
1145
+ if (uniforms) {
1146
+ const pixelH = uniforms.uSize.value.y * ctx.screenH * 0.8;
1147
+ const pixelW = uniforms.uSize.value.x * ctx.screenH * 0.8;
1148
+ targetAlpha = 1;
1149
+ if (targetAlpha > 0 && record.classKey === "chapter" && !isSpecial) {
1150
+ const dist = Math.sqrt(ndcX * ndcX + ndcY * ndcY);
1151
+ const fovWeight = THREE6.MathUtils.smoothstep(ctx.fov, 10, 40);
1152
+ const focusOuter = THREE6.MathUtils.lerp(0.82, 0.62, fovWeight);
1153
+ const focusInner = THREE6.MathUtils.lerp(0.24, 0.16, fovWeight);
1154
+ const centerFocus = 1 - THREE6.MathUtils.smoothstep(dist, focusInner, focusOuter);
1155
+ const chapterVisibility = THREE6.MathUtils.lerp(1, centerFocus, fovWeight);
1156
+ targetAlpha *= chapterVisibility;
1157
+ if (dist > focusOuter && ctx.fov > 12) {
1158
+ targetAlpha = 0;
1159
+ }
1160
+ if (dist < 0.18 && ctx.fov < 58) {
1161
+ targetAlpha = Math.max(targetAlpha, 0.55);
1162
+ }
1163
+ }
1164
+ if (targetAlpha > 0 && (record.classKey === "book" || record.classKey === "group") && !isSpecial) {
1165
+ const dist = Math.sqrt(ndcX * ndcX + ndcY * ndcY);
1166
+ const fovWeight = THREE6.MathUtils.smoothstep(ctx.fov, 15, 58);
1167
+ const focusOuter = THREE6.MathUtils.lerp(0.95, 0.7, fovWeight);
1168
+ const focusInner = THREE6.MathUtils.lerp(0.35, 0.22, fovWeight);
1169
+ const centerFocus = 1 - THREE6.MathUtils.smoothstep(dist, focusInner, focusOuter);
1170
+ const bookVisibility = THREE6.MathUtils.lerp(1, centerFocus, fovWeight);
1171
+ targetAlpha *= bookVisibility;
1172
+ if (dist > focusOuter && ctx.fov > 20) {
1173
+ targetAlpha = 0;
1174
+ }
1175
+ }
1176
+ if (targetAlpha > 0 && ctx.shouldFilter) {
1177
+ const node = record.label.node;
1178
+ if (node.level === 3) {
1179
+ targetAlpha = 0;
1180
+ } else if (node.level === 2 || node.level === 2.5) {
1181
+ if (ctx.isNodeFiltered(node)) targetAlpha = 0;
1182
+ }
1183
+ }
1184
+ if (targetAlpha > 0 && record.classKey === "chapter" && record.label.chapterStarWorldPos) {
1185
+ const starProj = ctx.project(record.label.chapterStarWorldPos);
1186
+ if (starProj.z <= 0.2) {
1187
+ const starNdcX = starProj.x * ctx.globalScale / ctx.aspect;
1188
+ const starNdcY = starProj.y * ctx.globalScale;
1189
+ const starSX = (starNdcX * 0.5 + 0.5) * ctx.screenW;
1190
+ const starSY = (-starNdcY * 0.5 + 0.5) * ctx.screenH;
1191
+ const rect = {
1192
+ x: sX - pixelW / 2,
1193
+ y: sY - pixelH / 2,
1194
+ w: pixelW,
1195
+ h: pixelH,
1196
+ priority: classBehavior.priority
1197
+ };
1198
+ const glowRadiusPx = record.label.chapterGlowRadiusPx ?? 18;
1199
+ const clearancePx = Math.max(1, glowRadiusPx * 0.02);
1200
+ const distToLabel = boundsDistPoint(rect, starSX, starSY);
1201
+ if (glowRadiusPx >= 70) {
1202
+ const threshold = glowRadiusPx + clearancePx;
1203
+ const visibility = THREE6.MathUtils.smoothstep(distToLabel, threshold - 4, threshold + 2);
1204
+ const lowFovRelief = 1 - THREE6.MathUtils.smoothstep(ctx.fov, 8, 18);
1205
+ const boostedVisibility = isSpecial ? Math.max(visibility, 0.92) : Math.max(visibility, lowFovRelief * 0.85);
1206
+ targetAlpha *= boostedVisibility;
1207
+ }
1208
+ }
1209
+ }
1210
+ if (record.classKey === "division" && uniforms.uAngle) {
1211
+ angleTarget = 0;
1212
+ if (ctx.projectionId !== "perspective") {
1213
+ const dx = sX - ctx.screenW / 2;
1214
+ const dy = sY - ctx.screenH / 2;
1215
+ angleTarget = Math.atan2(-dy, -dx) - Math.PI / 2;
1216
+ }
1217
+ }
1218
+ if (targetAlpha > 0) {
1219
+ const priorityBoost = isSpecial ? record.id === ctx.selectedId ? 400 : 300 : 0;
1220
+ candidates.push({
1221
+ record,
1222
+ behavior: classBehavior,
1223
+ uniforms,
1224
+ sX,
1225
+ sY,
1226
+ w: pixelW,
1227
+ h: pixelH,
1228
+ ndcX,
1229
+ ndcY,
1230
+ priority: classBehavior.priority + priorityBoost,
1231
+ isPinned: isSpecial || classBehavior.mode === "pinned",
1232
+ isSpecial,
1233
+ centerDist: Math.sqrt((sX - ctx.screenW * 0.5) ** 2 + (sY - ctx.screenH * 0.5) ** 2)
1234
+ });
1235
+ }
1236
+ }
1237
+ }
1238
+ }
1239
+ }
1240
+ if (typeof angleTarget === "number") {
1241
+ const material = record.label.obj.material;
1242
+ if (material instanceof THREE6.ShaderMaterial && material.uniforms.uAngle && typeof material.uniforms.uAngle.value === "number") {
1243
+ const current = material.uniforms.uAngle.value;
1244
+ material.uniforms.uAngle.value = lerp(current, angleTarget, 0.1);
1245
+ }
1246
+ }
1247
+ record.targetAlpha = targetAlpha;
1248
+ }
1249
+ candidates.sort((a, b) => {
1250
+ if (a.priority !== b.priority) return b.priority - a.priority;
1251
+ if (a.record.lastAccepted !== b.record.lastAccepted) return a.record.lastAccepted ? -1 : 1;
1252
+ return a.centerDist - b.centerDist;
1253
+ });
1254
+ let guaranteedCenterChapterId = null;
1255
+ if (ctx.toggles.showChapterLabels && ctx.fov <= behavior.classes.chapter.maxFov + 15) {
1256
+ const centerChapter = candidates.filter((c) => c.record.classKey === "chapter" && c.record.targetAlpha > 0).sort((a, b) => a.centerDist - b.centerDist)[0];
1257
+ if (centerChapter) {
1258
+ guaranteedCenterChapterId = centerChapter.record.id;
1259
+ centerChapter.record.targetAlpha = Math.max(centerChapter.record.targetAlpha, 0.85);
1260
+ }
1261
+ }
1262
+ const occupied = [];
1263
+ const accepted = /* @__PURE__ */ new Set();
1264
+ for (const c of candidates) {
1265
+ if (c.record.targetAlpha <= 0) continue;
1266
+ const rect = {
1267
+ x: c.sX - c.w / 2 - behavior.overlapPaddingPx,
1268
+ y: c.sY - c.h / 2 - behavior.overlapPaddingPx,
1269
+ w: c.w + behavior.overlapPaddingPx * 2,
1270
+ h: c.h + behavior.overlapPaddingPx * 2,
1271
+ priority: c.priority
1272
+ };
1273
+ let rejected = false;
1274
+ const isGuaranteedCenterChapter = c.record.id === guaranteedCenterChapterId;
1275
+ if (!c.isPinned && !isGuaranteedCenterChapter) {
1276
+ if (!c.record.lastAccepted && !c.isSpecial && c.record.lastRejectedAtMs > 0) {
1277
+ const sinceReject = ctx.nowMs - c.record.lastRejectedAtMs;
1278
+ if (sinceReject < behavior.reappearDelayMs) {
1279
+ rejected = true;
1280
+ }
1281
+ }
1282
+ if (!rejected) {
1283
+ for (const other of occupied) {
1284
+ if (other.priority < c.priority) continue;
1285
+ const overlapDepth = boundsOverlapDepth(rect, other);
1286
+ if (overlapDepth > c.behavior.maxOverlapPx) {
1287
+ rejected = true;
1288
+ break;
1289
+ }
1290
+ }
1291
+ }
1292
+ }
1293
+ if (rejected) {
1294
+ c.record.lastAccepted = false;
1295
+ c.record.lastRejectedAtMs = ctx.nowMs;
1296
+ continue;
1297
+ }
1298
+ occupied.push(rect);
1299
+ accepted.add(c.record.id);
1300
+ c.record.lastAccepted = true;
1301
+ }
1302
+ for (const record of this.records.values()) {
1303
+ const acceptedThisFrame = accepted.has(record.id) && record.targetAlpha > 0;
1304
+ record.fader.target = acceptedThisFrame;
1305
+ record.fader.update(ctx.dt);
1306
+ const baseAlpha = acceptedThisFrame ? record.targetAlpha : record.targetAlpha > 0 ? 1 : 0;
1307
+ const alpha = record.fader.eased * baseAlpha;
1308
+ applyUniformAlpha(record.label.obj, alpha);
1309
+ record.label.obj.visible = alpha > 0.01;
1310
+ if (!acceptedThisFrame) {
1311
+ record.lastAccepted = false;
1312
+ }
1313
+ }
848
1314
  }
849
1315
  };
850
1316
  }
@@ -871,6 +1337,7 @@ function createEngine({
871
1337
  }) {
872
1338
  let hoveredBookId = null;
873
1339
  let focusedBookId = null;
1340
+ let focusedNodeId = null;
874
1341
  let orderRevealEnabled = true;
875
1342
  let activeBookIndex = -1;
876
1343
  let orderRevealStrength = 0;
@@ -889,26 +1356,15 @@ function createEngine({
889
1356
  const bookIdToIndex = /* @__PURE__ */ new Map();
890
1357
  const testamentToIndex = /* @__PURE__ */ new Map();
891
1358
  const divisionToIndex = /* @__PURE__ */ new Map();
892
- const renderer = new THREE5.WebGLRenderer({ antialias: true, alpha: false });
1359
+ const renderer = new THREE6.WebGLRenderer({ antialias: true, alpha: false });
893
1360
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
894
1361
  renderer.setSize(container.clientWidth, container.clientHeight);
895
1362
  container.appendChild(renderer.domElement);
896
- const scene = new THREE5.Scene();
897
- scene.background = new THREE5.Color(0);
898
- const camera = new THREE5.PerspectiveCamera(60, 1, 0.1, 1e4);
1363
+ const scene = new THREE6.Scene();
1364
+ scene.background = null;
1365
+ const camera = new THREE6.PerspectiveCamera(60, 1, 0.1, 1e4);
899
1366
  camera.position.set(0, 0, 0);
900
1367
  camera.up.set(0, 1, 0);
901
- function setHoveredBook(id) {
902
- if (id === hoveredBookId) return;
903
- const now = performance.now();
904
- if (hoveredBookId) {
905
- hoverCooldowns.set(hoveredBookId, now);
906
- }
907
- if (id) {
908
- hoverCooldowns.get(id) || 0;
909
- }
910
- hoveredBookId = id;
911
- }
912
1368
  let running = false;
913
1369
  let raf = 0;
914
1370
  const state = {
@@ -946,21 +1402,120 @@ function createEngine({
946
1402
  longPressTimer: null,
947
1403
  longPressTriggered: false
948
1404
  };
949
- const mouseNDC = new THREE5.Vector2();
1405
+ const mouseNDC = new THREE6.Vector2();
950
1406
  let isMouseInWindow = false;
951
1407
  let isTouchDevice = false;
952
1408
  let edgeHoverStart = 0;
953
1409
  let handlers = { onSelect, onHover, onArrangementChange, onFovChange, onLongPress };
954
1410
  let currentConfig;
1411
+ function getSceneDebug() {
1412
+ return currentConfig?.debug?.sceneMechanics;
1413
+ }
1414
+ function getFreezeBand() {
1415
+ const dbg = getSceneDebug();
1416
+ const startRaw = dbg?.freezeBandStartFov ?? ENGINE_CONFIG.freezeBandStartFov;
1417
+ const endRaw = dbg?.freezeBandEndFov ?? ENGINE_CONFIG.freezeBandEndFov;
1418
+ const start2 = Math.min(startRaw, endRaw);
1419
+ const end = Math.max(startRaw, endRaw);
1420
+ return { start: start2, end };
1421
+ }
1422
+ function isInTransitionFreezeBand(fov) {
1423
+ const band = getFreezeBand();
1424
+ return fov >= band.start && fov <= band.end;
1425
+ }
1426
+ function getZenithBiasStartFov() {
1427
+ return getSceneDebug()?.zenithBiasStartFov ?? ENGINE_CONFIG.zenithBiasStartFov;
1428
+ }
1429
+ function getVerticalPanDampConfig() {
1430
+ const dbg = getSceneDebug();
1431
+ const fovStartRaw = dbg?.verticalPanDampStartFov ?? ENGINE_CONFIG.verticalPanDampStartFov;
1432
+ const fovEndRaw = dbg?.verticalPanDampEndFov ?? ENGINE_CONFIG.verticalPanDampEndFov;
1433
+ const latStartRaw = dbg?.verticalPanDampLatStartDeg ?? ENGINE_CONFIG.verticalPanDampLatStartDeg;
1434
+ const latEndRaw = dbg?.verticalPanDampLatEndDeg ?? ENGINE_CONFIG.verticalPanDampLatEndDeg;
1435
+ return {
1436
+ fovStart: Math.min(fovStartRaw, fovEndRaw),
1437
+ fovEnd: Math.max(fovStartRaw, fovEndRaw),
1438
+ latStartDeg: Math.min(latStartRaw, latEndRaw),
1439
+ latEndDeg: Math.max(latStartRaw, latEndRaw)
1440
+ };
1441
+ }
1442
+ function getVerticalPanFactor(fov, lat) {
1443
+ if (zenithProjectionLockActive) return 0;
1444
+ const cfg = getVerticalPanDampConfig();
1445
+ const fovT = THREE6.MathUtils.smoothstep(fov, cfg.fovStart, cfg.fovEnd);
1446
+ const zenithT = THREE6.MathUtils.smoothstep(
1447
+ Math.max(lat, 0),
1448
+ THREE6.MathUtils.degToRad(cfg.latStartDeg),
1449
+ THREE6.MathUtils.degToRad(cfg.latEndDeg)
1450
+ );
1451
+ const lock = Math.max(fovT * 0.65, fovT * zenithT);
1452
+ return THREE6.MathUtils.clamp(1 - lock, 0, 1);
1453
+ }
1454
+ function getMovementMassFactor(fov, wideFovFactor = ENGINE_CONFIG.movementMassWideFov) {
1455
+ const t = THREE6.MathUtils.smoothstep(fov, 24, 96);
1456
+ return THREE6.MathUtils.lerp(1, wideFovFactor, t);
1457
+ }
1458
+ function compressInputDelta(delta) {
1459
+ const absDelta = Math.abs(delta);
1460
+ if (absDelta < 1e-4) return 0;
1461
+ return Math.sign(delta) * (absDelta / (1 + absDelta * ENGINE_CONFIG.inputCompression));
1462
+ }
955
1463
  const constellationLayer = new ConstellationArtworkLayer(scene);
956
1464
  function mix(a, b, t) {
957
1465
  return a * (1 - t) + b * t;
958
1466
  }
959
1467
  let currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
1468
+ let zenithProjectionLockActive = false;
1469
+ function getZenithLockBlendThresholds() {
1470
+ const enterRaw = ENGINE_CONFIG.zenithLockBlendEnter;
1471
+ const exitRaw = ENGINE_CONFIG.zenithLockBlendExit;
1472
+ return {
1473
+ enter: Math.max(0, Math.min(1, enterRaw)),
1474
+ exit: Math.max(0, Math.min(1, Math.min(exitRaw, enterRaw)))
1475
+ };
1476
+ }
1477
+ function getZenithLockLat() {
1478
+ return Math.PI / 2 - 1e-3;
1479
+ }
1480
+ function getBlendForZenithControl() {
1481
+ if (currentProjection instanceof BlendedProjection) return currentProjection.getBlend();
1482
+ return 0;
1483
+ }
1484
+ function applyZenithAutoCenter() {
1485
+ const zenithLat = getZenithLockLat();
1486
+ const blend = getBlendForZenithControl();
1487
+ let pullT = THREE6.MathUtils.smoothstep(
1488
+ blend,
1489
+ ENGINE_CONFIG.zenithAutoCenterBlendStart,
1490
+ ENGINE_CONFIG.zenithAutoCenterBlendEnd
1491
+ );
1492
+ if (zenithProjectionLockActive) pullT = 1;
1493
+ if (pullT <= 1e-4) return;
1494
+ const pullLerp = THREE6.MathUtils.lerp(
1495
+ ENGINE_CONFIG.zenithAutoCenterMinLerp,
1496
+ ENGINE_CONFIG.zenithAutoCenterMaxLerp,
1497
+ pullT
1498
+ );
1499
+ state.lat = THREE6.MathUtils.lerp(state.lat, zenithLat, pullLerp);
1500
+ state.targetLat = THREE6.MathUtils.lerp(state.targetLat, zenithLat, Math.min(1, pullLerp * 1.15));
1501
+ state.velocityY *= 1 - 0.85 * pullT;
1502
+ if (zenithProjectionLockActive && Math.abs(state.lat - zenithLat) < 25e-5) {
1503
+ state.lat = zenithLat;
1504
+ state.targetLat = zenithLat;
1505
+ state.velocityY = 0;
1506
+ }
1507
+ }
960
1508
  function syncProjectionState() {
961
1509
  if (currentProjection instanceof BlendedProjection) {
962
1510
  currentProjection.setFov(state.fov);
1511
+ currentProjection.setBlendOverride(getSceneDebug()?.projectionBlendOverride ?? null);
963
1512
  globalUniforms.uBlend.value = currentProjection.getBlend();
1513
+ const blend = currentProjection.getBlend();
1514
+ const th = getZenithLockBlendThresholds();
1515
+ if (!zenithProjectionLockActive && blend >= th.enter) zenithProjectionLockActive = true;
1516
+ else if (zenithProjectionLockActive && blend <= th.exit) zenithProjectionLockActive = false;
1517
+ } else {
1518
+ zenithProjectionLockActive = false;
964
1519
  }
965
1520
  globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
966
1521
  }
@@ -987,7 +1542,7 @@ function createEngine({
987
1542
  const uvX = mouseNDC.x * aspectRatio;
988
1543
  const uvY = mouseNDC.y;
989
1544
  const v = currentProjection.inverse(uvX, uvY, fovRad);
990
- return new THREE5.Vector3(v.x, v.y, v.z).normalize();
1545
+ return new THREE6.Vector3(v.x, v.y, v.z).normalize();
991
1546
  }
992
1547
  function getMouseWorldVector(pixelX, pixelY, width, height) {
993
1548
  const aspect = width / height;
@@ -996,7 +1551,7 @@ function createEngine({
996
1551
  syncProjectionState();
997
1552
  const fovRad = state.fov * Math.PI / 180;
998
1553
  const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
999
- const vView = new THREE5.Vector3(v.x, v.y, v.z).normalize();
1554
+ const vView = new THREE6.Vector3(v.x, v.y, v.z).normalize();
1000
1555
  return vView.applyQuaternion(camera.quaternion);
1001
1556
  }
1002
1557
  function smartProjectJS(worldPos) {
@@ -1006,135 +1561,871 @@ function createEngine({
1006
1561
  if (!result) return { x: 0, y: 0, z: dir.z };
1007
1562
  return result;
1008
1563
  }
1009
- const groundGroup = new THREE5.Group();
1564
+ const groundGroup = new THREE6.Group();
1010
1565
  scene.add(groundGroup);
1566
+ const MAX_HORIZON_POINTS = 64;
1567
+ let groundMaterial = null;
1568
+ let horizonLine = null;
1569
+ let activeHorizonProfile = {
1570
+ mode: 0,
1571
+ pointCount: 0,
1572
+ azDeg: [],
1573
+ altDeg: [],
1574
+ rotateRad: 0,
1575
+ baseAltDeg: 3
1576
+ };
1577
+ let lastHorizonDiagTs = 0;
1578
+ function toColor(input, fallbackHex) {
1579
+ if (!input) return new THREE6.Color(fallbackHex);
1580
+ try {
1581
+ return new THREE6.Color(input);
1582
+ } catch {
1583
+ return new THREE6.Color(fallbackHex);
1584
+ }
1585
+ }
1586
+ function applyGroundTheme(cfg) {
1587
+ if (!groundMaterial) return;
1588
+ const theme = getSceneDebug()?.disableHorizonTheme ? void 0 : cfg?.horizonTheme;
1589
+ const uniforms = groundMaterial.uniforms;
1590
+ const atmo = theme?.atmosphere;
1591
+ const mode = theme?.source === "polygonal" && (theme.profile?.points?.length ?? 0) >= 2 ? 1 : 0;
1592
+ const groundColor = toColor(theme?.groundColor, 65794);
1593
+ const fogColor = toColor(theme?.horizonLineColor, 663098);
1594
+ const fogIntensity = THREE6.MathUtils.clamp(atmo?.fogIntensity ?? 0.6, 0, 1.5);
1595
+ const fogVisible = atmo?.fogVisible === false ? 0 : 1;
1596
+ const minBrightness = THREE6.MathUtils.clamp(atmo?.minimalBrightness ?? 0, 0, 1);
1597
+ const rotateRad = (theme?.profile?.angleRotateZDeg ?? 0) * Math.PI / 180;
1598
+ const azSamples = new Array(MAX_HORIZON_POINTS).fill(0);
1599
+ const altSamples = new Array(MAX_HORIZON_POINTS).fill(0);
1600
+ let pointCount = 0;
1601
+ let sortedPoints = [];
1602
+ if (mode === 1 && theme?.profile?.points) {
1603
+ sortedPoints = [...theme.profile.points].map((p) => ({
1604
+ azDeg: (p.azDeg % 360 + 360) % 360,
1605
+ altDeg: THREE6.MathUtils.clamp(p.altDeg, -30, 35)
1606
+ })).sort((a, b) => a.azDeg - b.azDeg);
1607
+ pointCount = Math.min(sortedPoints.length, MAX_HORIZON_POINTS);
1608
+ for (let i = 0; i < pointCount; i++) {
1609
+ azSamples[i] = sortedPoints[i].azDeg;
1610
+ altSamples[i] = sortedPoints[i].altDeg;
1611
+ }
1612
+ }
1613
+ const baseAltDeg = pointCount > 0 ? altSamples.slice(0, pointCount).reduce((sum, v) => sum + v, 0) / pointCount : 3;
1614
+ activeHorizonProfile = {
1615
+ mode,
1616
+ pointCount,
1617
+ azDeg: azSamples.slice(0, pointCount),
1618
+ altDeg: altSamples.slice(0, pointCount),
1619
+ rotateRad,
1620
+ baseAltDeg
1621
+ };
1622
+ uniforms.color.value = groundColor;
1623
+ uniforms.fogColor.value = fogColor;
1624
+ uniforms.uFogIntensity.value = fogIntensity;
1625
+ uniforms.uFogVisible.value = fogVisible;
1626
+ uniforms.uMinBrightness.value = minBrightness;
1627
+ uniforms.uHorizonMode.value = mode;
1628
+ uniforms.uHorizonPointCount.value = pointCount;
1629
+ uniforms.uHorizonAzDeg.value = azSamples;
1630
+ uniforms.uHorizonAltDeg.value = altSamples;
1631
+ uniforms.uHorizonRotateRad.value = rotateRad;
1632
+ uniforms.uBaseAltDeg.value = baseAltDeg;
1633
+ groundMaterial.uniformsNeedUpdate = true;
1634
+ if (atmosphereMesh && atmosphereMesh.material instanceof THREE6.ShaderMaterial) {
1635
+ const atmUniforms = atmosphereMesh.material.uniforms;
1636
+ const topAltDeg = atmo?.fogBandTopAltDeg ?? 90;
1637
+ const bottomAltDeg = atmo?.fogBandBottomAltDeg ?? -90;
1638
+ atmUniforms.uThemeFogVisible.value = fogVisible;
1639
+ atmUniforms.uThemeFogIntensity.value = fogIntensity;
1640
+ atmUniforms.uThemeFogTopSin.value = Math.sin(THREE6.MathUtils.degToRad(topAltDeg));
1641
+ atmUniforms.uThemeFogBottomSin.value = Math.sin(THREE6.MathUtils.degToRad(bottomAltDeg));
1642
+ atmUniforms.uThemeMinBrightness.value = minBrightness;
1643
+ atmosphereMesh.material.uniformsNeedUpdate = true;
1644
+ }
1645
+ if (horizonLine) {
1646
+ groundGroup.remove(horizonLine);
1647
+ horizonLine.geometry.dispose();
1648
+ horizonLine.material.dispose();
1649
+ horizonLine = null;
1650
+ }
1651
+ const lineThickness = THREE6.MathUtils.clamp(theme?.horizonLineThickness ?? 0, 0, 8);
1652
+ const shouldDrawLine = mode === 1 && pointCount >= 2 && lineThickness > 0;
1653
+ if (!shouldDrawLine) return;
1654
+ const lineColor = toColor(theme?.horizonLineColor, 5601177);
1655
+ const lineRadius = 997;
1656
+ const pts = [];
1657
+ for (let i = 0; i < pointCount; i++) {
1658
+ const sample = sortedPoints[i];
1659
+ const angleDeg = sample.azDeg - (theme?.profile?.angleRotateZDeg ?? 0);
1660
+ const a = THREE6.MathUtils.degToRad(angleDeg);
1661
+ const alt = THREE6.MathUtils.degToRad(sample.altDeg);
1662
+ const rc = Math.cos(alt);
1663
+ pts.push(new THREE6.Vector3(
1664
+ lineRadius * rc * Math.cos(a),
1665
+ lineRadius * Math.sin(alt),
1666
+ lineRadius * rc * Math.sin(a)
1667
+ ));
1668
+ }
1669
+ if (pts.length > 0) pts.push(pts[0].clone());
1670
+ const geo = new THREE6.BufferGeometry().setFromPoints(pts);
1671
+ const mat = createSmartMaterial({
1672
+ uniforms: {
1673
+ color: { value: lineColor },
1674
+ alpha: { value: 0.95 }
1675
+ },
1676
+ vertexShaderBody: `
1677
+ uniform vec3 color;
1678
+ varying vec3 vColor;
1679
+ void main() {
1680
+ vColor = color;
1681
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1682
+ gl_Position = smartProject(mvPosition);
1683
+ vScreenPos = gl_Position.xy / gl_Position.w;
1684
+ }
1685
+ `,
1686
+ fragmentShader: `
1687
+ uniform float alpha;
1688
+ varying vec3 vColor;
1689
+ void main() {
1690
+ float alphaMask = getMaskAlpha();
1691
+ if (alphaMask < 0.01) discard;
1692
+ gl_FragColor = vec4(vColor, alpha * alphaMask);
1693
+ }
1694
+ `,
1695
+ transparent: true,
1696
+ depthWrite: false,
1697
+ depthTest: true
1698
+ });
1699
+ const line = new THREE6.Line(geo, mat);
1700
+ line.material.linewidth = lineThickness;
1701
+ line.frustumCulled = false;
1702
+ line.renderOrder = 3;
1703
+ horizonLine = line;
1704
+ groundGroup.add(line);
1705
+ }
1706
+ function sampleActiveHorizonAltDeg(azDeg) {
1707
+ const profile = activeHorizonProfile;
1708
+ if (profile.mode !== 1 || profile.pointCount < 2) return profile.baseAltDeg;
1709
+ const query = ((azDeg + THREE6.MathUtils.radToDeg(profile.rotateRad)) % 360 + 360) % 360;
1710
+ const n = profile.pointCount;
1711
+ const firstAz = profile.azDeg[0];
1712
+ const firstAlt = profile.altDeg[0];
1713
+ for (let i = 1; i < n; i++) {
1714
+ const prevAz2 = profile.azDeg[i - 1];
1715
+ const prevAlt2 = profile.altDeg[i - 1];
1716
+ const curAz = profile.azDeg[i];
1717
+ const curAlt = profile.altDeg[i];
1718
+ if (query >= prevAz2 && query <= curAz) {
1719
+ const t2 = (query - prevAz2) / Math.max(1e-4, curAz - prevAz2);
1720
+ return mix(prevAlt2, curAlt, t2);
1721
+ }
1722
+ }
1723
+ const prevAz = profile.azDeg[n - 1];
1724
+ const prevAlt = profile.altDeg[n - 1];
1725
+ const wrappedQuery = query < firstAz ? query + 360 : query;
1726
+ const t = (wrappedQuery - prevAz) / Math.max(1e-4, firstAz + 360 - prevAz);
1727
+ return mix(prevAlt, firstAlt, t);
1728
+ }
1729
+ function runHorizonDiagnostics(nowMs) {
1730
+ if (nowMs - lastHorizonDiagTs < 1200) return;
1731
+ lastHorizonDiagTs = nowMs;
1732
+ const points = [];
1733
+ const r = 997;
1734
+ const scale = globalUniforms.uScale.value;
1735
+ const aspect = Math.max(1e-4, globalUniforms.uAspect.value);
1736
+ for (let az = 0; az < 360; az += 2) {
1737
+ const altDeg = sampleActiveHorizonAltDeg(az);
1738
+ const azRad = THREE6.MathUtils.degToRad(az);
1739
+ const altRad = THREE6.MathUtils.degToRad(altDeg);
1740
+ const rc = Math.cos(altRad);
1741
+ const worldPos = new THREE6.Vector3(
1742
+ r * rc * Math.cos(azRad),
1743
+ r * Math.sin(altRad),
1744
+ r * rc * Math.sin(azRad)
1745
+ );
1746
+ const p = smartProjectJS(worldPos);
1747
+ if (currentProjection.isClipped(p.z)) continue;
1748
+ const x = p.x * scale / aspect;
1749
+ const y = p.y * scale;
1750
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
1751
+ if (Math.abs(x) > 1) continue;
1752
+ points.push({ x, y });
1753
+ }
1754
+ if (points.length < 16) {
1755
+ console.debug(`[HorizonDiag] insufficient visible horizon samples at fov=${state.fov.toFixed(1)}`);
1756
+ return;
1757
+ }
1758
+ const binCount = 12;
1759
+ const maxY = new Array(binCount).fill(-Infinity);
1760
+ for (const p of points) {
1761
+ const ax = Math.min(0.999, Math.abs(p.x));
1762
+ const idx = Math.floor(ax * binCount);
1763
+ maxY[idx] = Math.max(maxY[idx], p.y);
1764
+ }
1765
+ const compact = maxY.map((v) => Number.isFinite(v) ? Number(v.toFixed(3)) : null);
1766
+ let dropCount = 0;
1767
+ for (let i = 1; i < binCount; i++) {
1768
+ const prev = maxY[i - 1];
1769
+ const cur = maxY[i];
1770
+ if (!Number.isFinite(prev) || !Number.isFinite(cur)) continue;
1771
+ if (cur < prev - 0.02) dropCount++;
1772
+ }
1773
+ const flatten = groundMaterial?.uniforms?.uZenithFlatten?.value;
1774
+ const blend = currentProjection instanceof BlendedProjection ? currentProjection.getBlend() : -1;
1775
+ const freeze = isInTransitionFreezeBand(state.fov) ? 1 : 0;
1776
+ const zenithBiasStart = getZenithBiasStartFov();
1777
+ const vPanCfg = getVerticalPanDampConfig();
1778
+ const vPan = getVerticalPanFactor(state.fov, state.lat);
1779
+ const moveMass = getMovementMassFactor(state.fov);
1780
+ console.debug(
1781
+ `[HorizonDiag] fov=${state.fov.toFixed(1)} latDeg=${THREE6.MathUtils.radToDeg(state.lat).toFixed(1)} mode=${activeHorizonProfile.mode} blend=${blend.toFixed(3)} freeze=${freeze} zLock=${zenithProjectionLockActive ? 1 : 0} biasStart=${zenithBiasStart.toFixed(1)} vPan=${vPan.toFixed(3)} moveMass=${moveMass.toFixed(3)} vPanFov=${vPanCfg.fovStart.toFixed(1)}-${vPanCfg.fovEnd.toFixed(1)} vPanLat=${vPanCfg.latStartDeg.toFixed(1)}-${vPanCfg.latEndDeg.toFixed(1)} flatten=${Number(flatten ?? 0).toFixed(3)} drops=${dropCount} bins=${JSON.stringify(compact)}`
1782
+ );
1783
+ }
1011
1784
  function createGround() {
1012
1785
  groundGroup.clear();
1013
1786
  const radius = 995;
1014
- const geometry = new THREE5.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
1787
+ const geometry = new THREE6.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
1015
1788
  const material = createSmartMaterial({
1016
1789
  uniforms: {
1017
- color: { value: new THREE5.Color(65794) },
1018
- fogColor: { value: new THREE5.Color(663098) }
1790
+ color: { value: new THREE6.Color(65794) },
1791
+ fogColor: { value: new THREE6.Color(663098) },
1792
+ uFogIntensity: { value: 0.6 },
1793
+ uFogVisible: { value: 1 },
1794
+ uMinBrightness: { value: 0 },
1795
+ uHorizonMode: { value: 0 },
1796
+ uHorizonPointCount: { value: 0 },
1797
+ uHorizonAzDeg: { value: new Array(MAX_HORIZON_POINTS).fill(0) },
1798
+ uHorizonAltDeg: { value: new Array(MAX_HORIZON_POINTS).fill(0) },
1799
+ uHorizonRotateRad: { value: 0 },
1800
+ uHorizonRadius: { value: radius },
1801
+ uBaseAltDeg: { value: 3 },
1802
+ uZenithFlatten: { value: 0 }
1019
1803
  },
1020
1804
  vertexShaderBody: `
1021
- varying vec3 vPos;
1022
- varying vec3 vWorldPos;
1023
- void main() {
1024
- vPos = position;
1025
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1026
- gl_Position = smartProject(mvPosition);
1027
- vScreenPos = gl_Position.xy / gl_Position.w;
1028
- vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
1805
+ varying vec3 vPos;
1806
+ varying vec3 vWorldPos;
1807
+ varying float vViewDirZ;
1808
+ void main() {
1809
+ vPos = position;
1810
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1811
+ gl_Position = smartProject(mvPosition);
1812
+ vScreenPos = gl_Position.xy / gl_Position.w;
1813
+ vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
1814
+ vViewDirZ = normalize(mvPosition.xyz).z;
1815
+ }
1816
+ `,
1817
+ fragmentShader: `
1818
+ uniform vec3 color;
1819
+ uniform vec3 fogColor;
1820
+ uniform float uFogIntensity;
1821
+ uniform float uFogVisible;
1822
+ uniform float uMinBrightness;
1823
+ uniform int uHorizonMode;
1824
+ uniform int uHorizonPointCount;
1825
+ uniform float uHorizonAzDeg[64];
1826
+ uniform float uHorizonAltDeg[64];
1827
+ uniform float uHorizonRotateRad;
1828
+ uniform float uHorizonRadius;
1829
+ uniform float uBaseAltDeg;
1830
+ uniform float uZenithFlatten;
1831
+ varying vec3 vPos;
1832
+ varying vec3 vWorldPos;
1833
+ varying float vViewDirZ;
1834
+
1835
+ float samplePolygonalAltDeg(float azDeg) {
1836
+ if (uHorizonPointCount < 2) return 0.0;
1837
+ float z = mod(azDeg, 360.0);
1838
+ if (z < 0.0) z += 360.0;
1839
+
1840
+ float prevAz = uHorizonAzDeg[0];
1841
+ float prevAlt = uHorizonAltDeg[0];
1842
+ for (int i = 1; i < 64; i++) {
1843
+ if (i >= uHorizonPointCount) break;
1844
+ float curAz = uHorizonAzDeg[i];
1845
+ float curAlt = uHorizonAltDeg[i];
1846
+ if (z >= prevAz && z <= curAz) {
1847
+ float t = (z - prevAz) / max(0.0001, curAz - prevAz);
1848
+ return mix(prevAlt, curAlt, t);
1849
+ }
1850
+ prevAz = curAz;
1851
+ prevAlt = curAlt;
1852
+ }
1853
+
1854
+ float firstAz = uHorizonAzDeg[0] + 360.0;
1855
+ float firstAlt = uHorizonAltDeg[0];
1856
+ float zw = z;
1857
+ if (zw < uHorizonAzDeg[0]) zw += 360.0;
1858
+ float t = (zw - prevAz) / max(0.0001, firstAz - prevAz);
1859
+ return mix(prevAlt, firstAlt, t);
1860
+ }
1861
+
1862
+ void main() {
1863
+ float alphaMask = getMaskAlpha();
1864
+ if (alphaMask < 0.01) discard;
1865
+
1866
+ // Keep ground visibility aligned with the active projection clip.
1867
+ float clipZ = -0.1;
1868
+ if (uProjectionType == 1) {
1869
+ clipZ = 0.1;
1870
+ } else if (uProjectionType == 2) {
1871
+ clipZ = mix(-0.1, 0.1, clamp(uBlend, 0.0, 1.0));
1872
+ }
1873
+ if (vViewDirZ > clipZ) discard;
1874
+
1875
+ float angle = atan(vPos.z, vPos.x);
1876
+ float terrainHeight;
1877
+
1878
+ if (uHorizonMode == 1 && uHorizonPointCount >= 2) {
1879
+ float azDeg = mod(degrees(angle) + 360.0 + degrees(uHorizonRotateRad), 360.0);
1880
+ float altDeg = samplePolygonalAltDeg(azDeg);
1881
+ terrainHeight = uHorizonRadius * sin(radians(altDeg));
1882
+ } else {
1883
+ // Procedural Horizon (Mountains)
1884
+ float h = 0.0;
1885
+ h += sin(angle * 6.0) * 35.0;
1886
+ h += sin(angle * 13.0 + 1.0) * 18.0;
1887
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1888
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1889
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1890
+ terrainHeight = h + 12.0;
1891
+ }
1892
+ float circularHeight = uHorizonRadius * sin(radians(uBaseAltDeg));
1893
+ terrainHeight = mix(terrainHeight, circularHeight, clamp(uZenithFlatten, 0.0, 1.0));
1894
+
1895
+ if (vPos.y > terrainHeight) discard;
1896
+
1897
+ // Atmospheric rim glow just below terrain peaks
1898
+ float rimDist = terrainHeight - vPos.y;
1899
+ float rim = exp(-rimDist * 0.15) * 0.4 * uFogVisible;
1900
+ vec3 rimColor = fogColor * 1.5;
1901
+
1902
+ // Atmospheric haze \u2014 stronger near horizon
1903
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1904
+ vec3 finalCol = mix(color, fogColor, fogFactor * uFogIntensity * uFogVisible);
1905
+
1906
+ // Add rim glow near terrain peaks
1907
+ finalCol += rimColor * rim;
1908
+ finalCol = max(finalCol, color * uMinBrightness);
1909
+
1910
+ gl_FragColor = vec4(finalCol, 1.0);
1911
+ }
1912
+ `,
1913
+ side: THREE6.BackSide,
1914
+ transparent: false,
1915
+ depthWrite: true,
1916
+ depthTest: true
1917
+ });
1918
+ groundMaterial = material;
1919
+ const ground = new THREE6.Mesh(geometry, material);
1920
+ groundGroup.add(ground);
1921
+ applyGroundTheme(currentConfig);
1922
+ }
1923
+ let skyBackgroundMesh = null;
1924
+ let atmosphereMesh = null;
1925
+ let moonMesh = null;
1926
+ let moonGlowMesh = null;
1927
+ let sunDiscMesh = null;
1928
+ let sunHaloMesh = null;
1929
+ let milkyWayMesh = null;
1930
+ function createSkyBackground() {
1931
+ const geo = new THREE6.SphereGeometry(2400, 32, 32);
1932
+ const mat = createSmartMaterial({
1933
+ uniforms: {},
1934
+ vertexShaderBody: `
1935
+ varying vec3 vWorldNormal;
1936
+ void main() {
1937
+ vWorldNormal = normalize(position);
1938
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
1939
+ gl_Position = smartProject(mv);
1940
+ vScreenPos = gl_Position.xy / gl_Position.w;
1941
+ }
1942
+ `,
1943
+ fragmentShader: `
1944
+ varying vec3 vWorldNormal;
1945
+ void main() {
1946
+ float h = clamp(normalize(vWorldNormal).y, -1.0, 1.0);
1947
+
1948
+ // Scotopic-inspired 5-stop gradient.
1949
+ // Night sky: blue channel ~2.6x red, derived from CIE (x=0.25, y=0.25).
1950
+ vec3 cZenith = vec3(0.010, 0.022, 0.055);
1951
+ vec3 cUpper = vec3(0.015, 0.033, 0.080);
1952
+ vec3 cMid = vec3(0.022, 0.048, 0.108);
1953
+ vec3 cLower = vec3(0.035, 0.072, 0.148);
1954
+ vec3 cHorizon = vec3(0.052, 0.100, 0.190);
1955
+
1956
+ float t1 = smoothstep(0.0, 0.30, h);
1957
+ float t2 = smoothstep(0.3, 0.60, h);
1958
+ float t3 = smoothstep(0.6, 0.85, h);
1959
+ float t4 = smoothstep(0.85, 1.00, h);
1960
+
1961
+ vec3 col = cHorizon;
1962
+ col = mix(col, cLower, t1);
1963
+ col = mix(col, cMid, t2);
1964
+ col = mix(col, cUpper, t3);
1965
+ col = mix(col, cZenith, t4);
1966
+
1967
+ // Rayleigh limb brightening at horizon
1968
+ float limb = exp(-18.0 * abs(h)) * smoothstep(-0.05, 0.06, h);
1969
+ col += vec3(0.012, 0.024, 0.050) * limb;
1970
+
1971
+ // Below ground: fade to near-black
1972
+ float below = smoothstep(-0.04, -0.18, h);
1973
+ col = mix(col, vec3(0.002, 0.003, 0.006), below);
1974
+
1975
+ gl_FragColor = vec4(col, 1.0);
1976
+ }
1977
+ `,
1978
+ transparent: false,
1979
+ depthWrite: false,
1980
+ depthTest: false,
1981
+ side: THREE6.BackSide
1982
+ });
1983
+ skyBackgroundMesh = new THREE6.Mesh(geo, mat);
1984
+ skyBackgroundMesh.renderOrder = -2;
1985
+ skyBackgroundMesh.frustumCulled = false;
1986
+ scene.add(skyBackgroundMesh);
1987
+ }
1988
+ function createAtmosphere() {
1989
+ const geometry = new THREE6.SphereGeometry(990, 64, 64);
1990
+ const material = createSmartMaterial({
1991
+ uniforms: {
1992
+ uThemeFogVisible: { value: 1 },
1993
+ uThemeFogTopSin: { value: 0.95 },
1994
+ uThemeFogBottomSin: { value: -1 },
1995
+ uThemeFogIntensity: { value: 1 },
1996
+ uThemeMinBrightness: { value: 0 }
1997
+ },
1998
+ vertexShaderBody: `
1999
+ varying vec3 vWorldNormal;
2000
+ void main() {
2001
+ vWorldNormal = normalize(position);
2002
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
2003
+ gl_Position = smartProject(mv);
2004
+ vScreenPos = gl_Position.xy / gl_Position.w;
2005
+ }`,
2006
+ fragmentShader: `
2007
+ varying vec3 vWorldNormal;
2008
+
2009
+ uniform float uAtmGlow;
2010
+ uniform float uAtmDark;
2011
+ uniform vec3 uColorHorizon;
2012
+ uniform vec3 uColorZenith;
2013
+ uniform float uThemeFogVisible;
2014
+ uniform float uThemeFogTopSin;
2015
+ uniform float uThemeFogBottomSin;
2016
+ uniform float uThemeFogIntensity;
2017
+ uniform float uThemeMinBrightness;
2018
+
2019
+ void main() {
2020
+ float alphaMask = getMaskAlpha();
2021
+ if (alphaMask < 0.01) discard;
2022
+
2023
+ // Altitude angle (Y is up)
2024
+ float h = normalize(vWorldNormal).y;
2025
+
2026
+ // 1. Base gradient from Horizon to Zenith (wider range)
2027
+ float t = smoothstep(-0.15, 0.7, h);
2028
+
2029
+ // Non-linear mix for realistic sky falloff
2030
+ vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
2031
+ float hazeBand = smoothstep(uThemeFogBottomSin, uThemeFogTopSin, h);
2032
+ float hazeFadeEnd = max(uThemeFogTopSin + 0.001, min(1.0, uThemeFogTopSin + 0.25));
2033
+ hazeBand *= (1.0 - smoothstep(uThemeFogTopSin, hazeFadeEnd, h));
2034
+ float fogTheme = uThemeFogVisible * uThemeFogIntensity;
2035
+
2036
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
2037
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
2038
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
2039
+
2040
+ // 3. Primary horizon glow band (wider than before)
2041
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
2042
+ skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow * fogTheme * max(0.15, hazeBand);
2043
+
2044
+ // 4. Warm secondary glow (light pollution / sodium scatter)
2045
+ float warmGlow = exp(-8.0 * abs(h));
2046
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow * fogTheme * max(0.15, hazeBand);
2047
+ skyColor = max(skyColor, uColorZenith * (0.2 * uThemeMinBrightness));
2048
+
2049
+ gl_FragColor = vec4(skyColor, 1.0);
2050
+ }
2051
+ `,
2052
+ side: THREE6.BackSide,
2053
+ depthWrite: false,
2054
+ depthTest: true
2055
+ });
2056
+ const atm = new THREE6.Mesh(geometry, material);
2057
+ atmosphereMesh = atm;
2058
+ groundGroup.add(atm);
2059
+ }
2060
+ function createMoon() {
2061
+ const moonDir = new THREE6.Vector3(-0.38, 0.62, -0.68).normalize();
2062
+ const moonWorldPos = moonDir.clone().multiplyScalar(2e3);
2063
+ const glowGeo = new THREE6.PlaneGeometry(1, 1);
2064
+ const glowMat = createSmartMaterial({
2065
+ uniforms: { uMoonSize: { value: 0.082 } },
2066
+ vertexShaderBody: `
2067
+ uniform float uMoonSize;
2068
+ varying vec2 vUv;
2069
+ void main() {
2070
+ vUv = uv;
2071
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2072
+ vec4 projected = smartProject(mvPos);
2073
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2074
+ vec2 offset = position.xy * uMoonSize * uScale * 2.4;
2075
+ projected.xy += offset / vec2(uAspect, 1.0);
2076
+ vScreenPos = projected.xy / projected.w;
2077
+ gl_Position = projected;
2078
+ }
2079
+ `,
2080
+ fragmentShader: `
2081
+ varying vec2 vUv;
2082
+ void main() {
2083
+ float alphaMask = getMaskAlpha();
2084
+ if (alphaMask < 0.01) discard;
2085
+ vec2 p = vUv * 2.0 - 1.0;
2086
+ float d = length(p);
2087
+ if (d > 1.0) discard;
2088
+ float halo = exp(-5.0 * d * d) * 0.07;
2089
+ halo += exp(-2.5 * max(0.0, d - 0.42)) * 0.045;
2090
+ if (halo < 0.003) discard;
2091
+ gl_FragColor = vec4(vec3(0.78, 0.88, 1.0) * halo, halo * alphaMask);
2092
+ }
2093
+ `,
2094
+ transparent: true,
2095
+ depthWrite: false,
2096
+ depthTest: true,
2097
+ blending: THREE6.AdditiveBlending
2098
+ });
2099
+ moonGlowMesh = new THREE6.Mesh(glowGeo, glowMat);
2100
+ moonGlowMesh.position.copy(moonWorldPos);
2101
+ moonGlowMesh.frustumCulled = false;
2102
+ moonGlowMesh.renderOrder = 2;
2103
+ scene.add(moonGlowMesh);
2104
+ const discGeo = new THREE6.PlaneGeometry(1, 1);
2105
+ const discMat = createSmartMaterial({
2106
+ uniforms: { uMoonSize: { value: 0.082 } },
2107
+ vertexShaderBody: `
2108
+ uniform float uMoonSize;
2109
+ varying vec2 vUv;
2110
+ void main() {
2111
+ vUv = uv;
2112
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2113
+ vec4 projected = smartProject(mvPos);
2114
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2115
+ vec2 offset = position.xy * uMoonSize * uScale;
2116
+ projected.xy += offset / vec2(uAspect, 1.0);
2117
+ vScreenPos = projected.xy / projected.w;
2118
+ gl_Position = projected;
2119
+ }
2120
+ `,
2121
+ fragmentShader: `
2122
+ varying vec2 vUv;
2123
+ void main() {
2124
+ float alphaMask = getMaskAlpha();
2125
+ if (alphaMask < 0.01) discard;
2126
+ vec2 p = vUv * 2.0 - 1.0;
2127
+ float d = length(p);
2128
+ if (d > 1.0) discard;
2129
+
2130
+ float edge = smoothstep(1.0, 0.90, d);
2131
+
2132
+ // Phase: sunlight from upper-right (gibbous moon)
2133
+ vec2 sunDir2D = normalize(vec2(0.55, 0.45));
2134
+ float phaseRaw = dot(normalize(p + vec2(0.0001)), sunDir2D);
2135
+ float lit = smoothstep(-0.18, 0.32, phaseRaw);
2136
+
2137
+ // Limb darkening (classical sqrt law)
2138
+ float cosTheta = sqrt(max(0.001, 1.0 - d * d));
2139
+ float limb = cosTheta * 0.42 + 0.58;
2140
+
2141
+ // Procedural surface texture
2142
+ float angle = atan(p.y, p.x);
2143
+ float r = d;
2144
+ float detail = sin(angle * 5.0 + 2.1) * sin(r * 8.3) * 0.038
2145
+ + sin(angle * 11.0 - 1.3) * sin(r * 13.0) * 0.022
2146
+ + sin(angle * 2.0 + 0.8) * (1.0 - r) * 0.055
2147
+ + sin(angle * 17.0 + r * 6.5) * 0.014
2148
+ + sin(angle * 23.0 - r * 11.0) * 0.009;
2149
+
2150
+ // Mare (dark maria) patches
2151
+ float mare1 = 1.0 - smoothstep(0.0, 0.30, length(p - vec2(-0.20, 0.22)));
2152
+ float mare2 = 1.0 - smoothstep(0.0, 0.20, length(p - vec2( 0.10, 0.30)));
2153
+ float mare3 = 1.0 - smoothstep(0.0, 0.24, length(p - vec2( 0.17,-0.06)));
2154
+ float mare4 = 1.0 - smoothstep(0.0, 0.14, length(p - vec2(-0.30,-0.20)));
2155
+ float totalMare = clamp(mare1*0.50 + mare2*0.38 + mare3*0.32 + mare4*0.28, 0.0, 0.58);
2156
+
2157
+ vec3 highland = vec3(0.88, 0.85, 0.80);
2158
+ vec3 mareColor = vec3(0.40, 0.39, 0.37);
2159
+ vec3 moonBase = clamp(mix(highland, mareColor, totalMare) + detail, 0.0, 1.0);
2160
+
2161
+ vec3 litSurface = moonBase * limb;
2162
+ vec3 earthshine = vec3(0.038, 0.052, 0.078);
2163
+ vec3 finalColor = mix(earthshine, litSurface, lit);
2164
+
2165
+ gl_FragColor = vec4(finalColor * edge, edge * alphaMask);
2166
+ }
2167
+ `,
2168
+ transparent: true,
2169
+ depthWrite: true,
2170
+ depthTest: true,
2171
+ blending: THREE6.NormalBlending
2172
+ });
2173
+ moonMesh = new THREE6.Mesh(discGeo, discMat);
2174
+ moonMesh.position.copy(moonWorldPos);
2175
+ moonMesh.frustumCulled = false;
2176
+ moonMesh.renderOrder = 3;
2177
+ scene.add(moonMesh);
2178
+ }
2179
+ function createSun() {
2180
+ const sunDir = new THREE6.Vector3(-1, -0.08, 0).normalize();
2181
+ const sunWorldPos = sunDir.clone().multiplyScalar(2e3);
2182
+ const haloGeo = new THREE6.PlaneGeometry(1, 1);
2183
+ const haloMat = createSmartMaterial({
2184
+ uniforms: { uSunHaloSize: { value: 0.46 } },
2185
+ vertexShaderBody: `
2186
+ uniform float uSunHaloSize;
2187
+ varying vec2 vUv;
2188
+ void main() {
2189
+ vUv = uv;
2190
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2191
+ vec4 projected = smartProject(mvPos);
2192
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2193
+ vec2 offset = position.xy * uSunHaloSize * uScale;
2194
+ projected.xy += offset / vec2(uAspect, 1.0);
2195
+ vScreenPos = projected.xy / projected.w;
2196
+ gl_Position = projected;
2197
+ }
2198
+ `,
2199
+ fragmentShader: `
2200
+ varying vec2 vUv;
2201
+ void main() {
2202
+ float alphaMask = getMaskAlpha();
2203
+ if (alphaMask < 0.01) discard;
2204
+
2205
+ vec2 p = vUv * 2.0 - 1.0;
2206
+ float d = length(p);
2207
+ if (d > 1.0) discard;
2208
+
2209
+ // Asymmetric falloff: spread wider horizontally than vertically
2210
+ float asymDist = length(vec2(p.x * 0.55, p.y));
2211
+
2212
+ // Radial glow: warm near centre, fading outward
2213
+ float glow = exp(-2.8 * asymDist * asymDist) * 1.0;
2214
+ glow += exp(-1.0 * asymDist) * 0.35;
2215
+
2216
+ // Crepuscular rays: fan out from bottom, visible above sun centre
2217
+ float rayMask = smoothstep(-0.05, 0.35, p.y);
2218
+ float rayFade = max(0.0, 1.0 - d) * (1.0 - d);
2219
+ float rayAngle = atan(p.x, max(0.0001, p.y)); // angle from vertical
2220
+ float rays = pow(abs(sin(rayAngle * 7.0 + 0.30)), 9.0) * 0.10
2221
+ + pow(abs(sin(rayAngle * 13.0 - 1.10)), 14.0) * 0.07
2222
+ + pow(abs(sin(rayAngle * 19.0 + 2.30)), 11.0) * 0.05;
2223
+ rays *= rayMask * rayFade;
2224
+
2225
+ // Colour: white-yellow \u2192 orange \u2192 hot-pink \u2192 purple
2226
+ vec3 cYellow = vec3(1.00, 0.88, 0.52);
2227
+ vec3 cOrange = vec3(1.00, 0.42, 0.10);
2228
+ vec3 cPink = vec3(0.90, 0.22, 0.52);
2229
+ vec3 cPurple = vec3(0.38, 0.12, 0.48);
2230
+ vec3 col = mix(cYellow, cOrange, smoothstep(0.00, 0.40, asymDist));
2231
+ col = mix(col, cPink, smoothstep(0.35, 0.72, asymDist));
2232
+ col = mix(col, cPurple, smoothstep(0.65, 1.00, asymDist));
2233
+
2234
+ float total = (glow + rays) * alphaMask;
2235
+ if (total < 0.005) discard;
2236
+ gl_FragColor = vec4(col * total, total);
2237
+ }
2238
+ `,
2239
+ transparent: true,
2240
+ depthWrite: false,
2241
+ depthTest: true,
2242
+ blending: THREE6.AdditiveBlending
2243
+ });
2244
+ sunHaloMesh = new THREE6.Mesh(haloGeo, haloMat);
2245
+ sunHaloMesh.position.copy(sunWorldPos);
2246
+ sunHaloMesh.frustumCulled = false;
2247
+ sunHaloMesh.renderOrder = 1;
2248
+ scene.add(sunHaloMesh);
2249
+ const discGeo = new THREE6.PlaneGeometry(1, 1);
2250
+ const discMat = createSmartMaterial({
2251
+ uniforms: { uSunSize: { value: 0.09 } },
2252
+ vertexShaderBody: `
2253
+ uniform float uSunSize;
2254
+ varying vec2 vUv;
2255
+ void main() {
2256
+ vUv = uv;
2257
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
2258
+ vec4 projected = smartProject(mvPos);
2259
+ if (projected.z > 4.0) { gl_Position = vec4(10.0, 10.0, 10.0, 1.0); return; }
2260
+ vec2 offset = position.xy * uSunSize * uScale;
2261
+ projected.xy += offset / vec2(uAspect, 1.0);
2262
+ vScreenPos = projected.xy / projected.w;
2263
+ gl_Position = projected;
1029
2264
  }
1030
2265
  `,
1031
2266
  fragmentShader: `
1032
- uniform vec3 color;
1033
- uniform vec3 fogColor;
1034
- varying vec3 vPos;
1035
- varying vec3 vWorldPos;
1036
-
1037
- void main() {
1038
- float alphaMask = getMaskAlpha();
1039
- if (alphaMask < 0.01) discard;
1040
-
1041
- // Procedural Horizon (Mountains)
1042
- float angle = atan(vPos.z, vPos.x);
1043
-
1044
- // FBM-like terrain with increased amplitude
1045
- float h = 0.0;
1046
- h += sin(angle * 6.0) * 35.0;
1047
- h += sin(angle * 13.0 + 1.0) * 18.0;
1048
- h += sin(angle * 29.0 + 2.0) * 8.0;
1049
- h += sin(angle * 63.0 + 4.0) * 3.0;
1050
- h += sin(angle * 97.0 + 5.0) * 1.5;
2267
+ varying vec2 vUv;
2268
+ void main() {
2269
+ float alphaMask = getMaskAlpha();
2270
+ if (alphaMask < 0.01) discard;
1051
2271
 
1052
- float terrainHeight = h + 12.0;
2272
+ vec2 p = vUv * 2.0 - 1.0;
2273
+ float d = length(p);
2274
+ if (d > 1.0) discard;
1053
2275
 
1054
- if (vPos.y > terrainHeight) discard;
2276
+ float edge = smoothstep(1.0, 0.86, d);
1055
2277
 
1056
- // Atmospheric rim glow just below terrain peaks
1057
- float rimDist = terrainHeight - vPos.y;
1058
- float rim = exp(-rimDist * 0.15) * 0.4;
1059
- vec3 rimColor = fogColor * 1.5;
2278
+ // Photosphere limb darkening: bright white core \u2192 orange limb
2279
+ float core = smoothstep(0.28, 0.00, d);
2280
+ float mid = smoothstep(0.68, 0.22, d) * (1.0 - core);
2281
+ float limb = (1.0 - smoothstep(0.70, 1.00, d)) * (1.0 - core - mid);
1060
2282
 
1061
- // Atmospheric haze \u2014 stronger near horizon
1062
- float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1063
- vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
2283
+ vec3 cCore = vec3(1.00, 0.97, 0.88); // hot white
2284
+ vec3 cMid = vec3(1.00, 0.80, 0.38); // yellow
2285
+ vec3 cLimb = vec3(1.00, 0.52, 0.08); // deep orange
1064
2286
 
1065
- // Add rim glow near terrain peaks
1066
- finalCol += rimColor * rim;
2287
+ vec3 col = cCore * (core + 0.12) + cMid * mid + cLimb * limb;
2288
+ col = clamp(col, 0.0, 1.5); // allow slight overbright
1067
2289
 
1068
- gl_FragColor = vec4(finalCol, 1.0);
2290
+ gl_FragColor = vec4(col * edge, edge * alphaMask);
1069
2291
  }
1070
2292
  `,
1071
- side: THREE5.BackSide,
1072
- transparent: false,
2293
+ transparent: true,
1073
2294
  depthWrite: true,
1074
- depthTest: true
2295
+ depthTest: true,
2296
+ blending: THREE6.NormalBlending
1075
2297
  });
1076
- const ground = new THREE5.Mesh(geometry, material);
1077
- groundGroup.add(ground);
2298
+ sunDiscMesh = new THREE6.Mesh(discGeo, discMat);
2299
+ sunDiscMesh.position.copy(sunWorldPos);
2300
+ sunDiscMesh.frustumCulled = false;
2301
+ sunDiscMesh.renderOrder = 3;
2302
+ scene.add(sunDiscMesh);
1078
2303
  }
1079
- let atmosphereMesh = null;
1080
- function createAtmosphere() {
1081
- const geometry = new THREE5.SphereGeometry(990, 64, 64);
1082
- const material = createSmartMaterial({
2304
+ function createMilkyWay() {
2305
+ if (milkyWayMesh) {
2306
+ scene.remove(milkyWayMesh);
2307
+ milkyWayMesh.geometry.dispose();
2308
+ milkyWayMesh.material.dispose();
2309
+ milkyWayMesh = null;
2310
+ }
2311
+ const geo = new THREE6.PlaneGeometry(1100, 380, 4, 4);
2312
+ const mat = createSmartMaterial({
2313
+ uniforms: {},
1083
2314
  vertexShaderBody: `
1084
- varying vec3 vWorldNormal;
1085
- void main() {
1086
- vWorldNormal = normalize(position);
1087
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
1088
- gl_Position = smartProject(mv);
2315
+ varying vec2 vUv;
2316
+ void main() {
2317
+ vUv = uv;
2318
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
2319
+ gl_Position = smartProject(mv);
1089
2320
  vScreenPos = gl_Position.xy / gl_Position.w;
1090
- }`,
2321
+ }
2322
+ `,
1091
2323
  fragmentShader: `
1092
- varying vec3 vWorldNormal;
1093
-
1094
- uniform float uAtmGlow;
1095
- uniform float uAtmDark;
1096
- uniform vec3 uColorHorizon;
1097
- uniform vec3 uColorZenith;
1098
-
2324
+ varying vec2 vUv;
2325
+
2326
+ // --- Noise helpers ---
2327
+ float hash(vec2 p) {
2328
+ p = fract(p * vec2(127.1, 311.7));
2329
+ p += dot(p, p + 19.19);
2330
+ return fract(p.x * p.y);
2331
+ }
2332
+ float vnoise(vec2 p) {
2333
+ vec2 i = floor(p); vec2 f = fract(p);
2334
+ f = f * f * (3.0 - 2.0 * f);
2335
+ return mix(
2336
+ mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
2337
+ mix(hash(i + vec2(0.0,1.0)), hash(i + vec2(1.0,1.0)), f.x), f.y
2338
+ );
2339
+ }
2340
+ float fbm(vec2 p) {
2341
+ float v = 0.0; float a = 0.5;
2342
+ mat2 m = mat2(1.6, 1.2, -1.2, 1.6);
2343
+ for (int i = 0; i < 7; i++) { v += a * vnoise(p); p = m * p; a *= 0.5; }
2344
+ return v;
2345
+ }
2346
+
1099
2347
  void main() {
1100
2348
  float alphaMask = getMaskAlpha();
1101
2349
  if (alphaMask < 0.01) discard;
1102
2350
 
1103
- // Altitude angle (Y is up)
1104
- float h = normalize(vWorldNormal).y;
2351
+ vec2 uv = vUv * 2.0 - 1.0; // -1..1 centred
1105
2352
 
1106
- // 1. Base gradient from Horizon to Zenith (wider range)
1107
- float t = smoothstep(-0.15, 0.7, h);
2353
+ // Galactic band: tight Gaussian falloff vertically
2354
+ float bandMask = exp(-uv.y * uv.y * 10.0);
1108
2355
 
1109
- // Non-linear mix for realistic sky falloff
1110
- vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
2356
+ // Warp UV for organic turbulence (two layers of distortion)
2357
+ vec2 q = vec2(fbm(uv * 1.5),
2358
+ fbm(uv * 1.5 + vec2(5.2, 1.3)));
2359
+ vec2 r = vec2(fbm(uv * 1.0 + 4.0 * q + vec2(1.7, 9.2)),
2360
+ fbm(uv * 1.0 + 4.0 * q + vec2(8.3, 2.8)));
1111
2361
 
1112
- // 2. Teal tint at mid-altitudes (subtle colour variation)
1113
- float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1114
- skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
2362
+ float nebula = fbm(uv * 2.0 + 2.0 * r);
2363
+ float detail = fbm(uv * 5.0 + r * 3.0 + vec2(3.1, 2.7));
2364
+ float fine = fbm(uv * 10.0 + vec2(1.0, 5.0));
1115
2365
 
1116
- // 3. Primary horizon glow band (wider than before)
1117
- float horizonBand = exp(-10.0 * abs(h - 0.02));
1118
- skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
2366
+ // Base density
2367
+ float density = smoothstep(0.30, 0.80, nebula) * bandMask;
2368
+ density += smoothstep(0.45, 0.85, detail) * bandMask * 0.35;
1119
2369
 
1120
- // 4. Warm secondary glow (light pollution / sodium scatter)
1121
- float warmGlow = exp(-8.0 * abs(h));
1122
- skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
2370
+ // Dust lanes \u2014 dark patches carved into the band
2371
+ float dust = fbm(uv * 3.5 + vec2(11.0, 7.0));
2372
+ density *= (1.0 - smoothstep(0.52, 0.62, dust) * 0.7 * bandMask);
1123
2373
 
1124
- gl_FragColor = vec4(skyColor, 1.0);
2374
+ // Galactic core boost toward horizontal centre
2375
+ float galCore = exp(-uv.x * uv.x * 1.2) * bandMask;
2376
+
2377
+ // --- Color palette ---
2378
+ vec3 deepBlue = vec3(0.10, 0.15, 0.45);
2379
+ vec3 midBlue = vec3(0.25, 0.30, 0.65);
2380
+ vec3 purple = vec3(0.40, 0.20, 0.60);
2381
+ vec3 coreWarm = vec3(0.85, 0.80, 0.65); // warm star-cluster glow
2382
+ vec3 pinkNeb = vec3(0.65, 0.28, 0.50); // emission nebula pink
2383
+
2384
+ float t1 = smoothstep(0.3, 0.7, nebula);
2385
+ float t2 = smoothstep(0.5, 0.8, detail);
2386
+ float t3 = smoothstep(0.55, 0.75, fine);
2387
+
2388
+ vec3 color = mix(deepBlue, midBlue, t1);
2389
+ color = mix(color, purple, t2 * 0.5);
2390
+ color = mix(color, pinkNeb, t3 * 0.25 * bandMask);
2391
+ color += coreWarm * galCore * 0.45 * density;
2392
+
2393
+ // Micro-star field \u2014 denser in the band
2394
+ float starThresh = mix(0.975, 0.940, bandMask);
2395
+ float starSeed = hash(floor(vUv * 500.0));
2396
+ float star = step(starThresh, starSeed);
2397
+ float starBright = hash(floor(vUv * 500.0) + 37.0);
2398
+ color += vec3(0.90, 0.95, 1.0) * star * (0.4 + 0.6 * starBright);
2399
+ density = max(density, star * bandMask * 0.5);
2400
+
2401
+ // Soft edge vignette
2402
+ float ex = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
2403
+ float ey = smoothstep(0.0, 0.18, vUv.y) * smoothstep(1.0, 0.82, vUv.y);
2404
+
2405
+ float alpha = density * ex * ey * alphaMask * 0.80;
2406
+ if (alpha < 0.004) discard;
2407
+ gl_FragColor = vec4(color, alpha);
1125
2408
  }
1126
2409
  `,
1127
- side: THREE5.BackSide,
2410
+ transparent: true,
1128
2411
  depthWrite: false,
1129
- depthTest: true
2412
+ depthTest: true,
2413
+ side: THREE6.DoubleSide,
2414
+ blending: THREE6.AdditiveBlending
1130
2415
  });
1131
- const atm = new THREE5.Mesh(geometry, material);
1132
- atmosphereMesh = atm;
1133
- groundGroup.add(atm);
2416
+ milkyWayMesh = new THREE6.Mesh(geo, mat);
2417
+ const mwDir = new THREE6.Vector3(-0.62, 0.6, -0.5).normalize();
2418
+ milkyWayMesh.position.copy(mwDir.clone().multiplyScalar(920));
2419
+ milkyWayMesh.lookAt(0, 0, 0);
2420
+ milkyWayMesh.rotateY(Math.PI);
2421
+ milkyWayMesh.frustumCulled = false;
2422
+ milkyWayMesh.renderOrder = 1;
2423
+ scene.add(milkyWayMesh);
1134
2424
  }
1135
- const backdropGroup = new THREE5.Group();
2425
+ const backdropGroup = new THREE6.Group();
1136
2426
  scene.add(backdropGroup);
1137
- function createBackdropStars(count = 31e3) {
2427
+ let backdropStarsMaterial = null;
2428
+ function createBackdropStars(count = 5e3) {
1138
2429
  backdropGroup.clear();
1139
2430
  while (backdropGroup.children.length > 0) {
1140
2431
  const c = backdropGroup.children[0];
@@ -1142,7 +2433,7 @@ function createEngine({
1142
2433
  if (c.geometry) c.geometry.dispose();
1143
2434
  if (c.material) c.material.dispose();
1144
2435
  }
1145
- const geometry = new THREE5.BufferGeometry();
2436
+ const geometry = new THREE6.BufferGeometry();
1146
2437
  const positions = [];
1147
2438
  const sizes = [];
1148
2439
  const colors = [];
@@ -1177,14 +2468,18 @@ function createEngine({
1177
2468
  }
1178
2469
  colors.push(cr, cg, cb);
1179
2470
  }
1180
- geometry.setAttribute("position", new THREE5.Float32BufferAttribute(positions, 3));
1181
- geometry.setAttribute("size", new THREE5.Float32BufferAttribute(sizes, 1));
1182
- geometry.setAttribute("color", new THREE5.Float32BufferAttribute(colors, 3));
2471
+ geometry.setAttribute("position", new THREE6.Float32BufferAttribute(positions, 3));
2472
+ geometry.setAttribute("size", new THREE6.Float32BufferAttribute(sizes, 1));
2473
+ geometry.setAttribute("color", new THREE6.Float32BufferAttribute(colors, 3));
1183
2474
  const material = createSmartMaterial({
1184
2475
  uniforms: {
1185
2476
  pixelRatio: { value: renderer.getPixelRatio() },
1186
2477
  uScale: globalUniforms.uScale,
1187
- uTime: globalUniforms.uTime
2478
+ uTime: globalUniforms.uTime,
2479
+ uBackdropGain: { value: 1 },
2480
+ uBackdropEnergy: { value: 2.2 },
2481
+ uBackdropSizeExp: { value: 0.9 },
2482
+ uRevealZoom: { value: 0 }
1188
2483
  },
1189
2484
  vertexShaderBody: `
1190
2485
  attribute float size;
@@ -1195,6 +2490,10 @@ function createEngine({
1195
2490
  uniform float uAtmExtinction;
1196
2491
  uniform float uAtmTwinkle;
1197
2492
  uniform float uTime;
2493
+ uniform float uBackdropGain;
2494
+ uniform float uBackdropEnergy;
2495
+ uniform float uBackdropSizeExp;
2496
+ uniform float uRevealZoom;
1198
2497
 
1199
2498
  void main() {
1200
2499
  vec3 nPos = normalize(position);
@@ -1210,15 +2509,21 @@ function createEngine({
1210
2509
  float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1211
2510
  float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
1212
2511
 
1213
- vColor = color * 3.0 * extinction * horizonFade * scintillation;
2512
+ // Backdrop appears latest \u2014 fully hidden at wide FOV, emerges when zoomed in.
2513
+ // Thresholds come from ZOOM_REVEAL_CONFIG (baked at startup).
2514
+ float mappedZoom = pow(uRevealZoom, ${ZOOM_REVEAL_CONFIG.zoomCurveExp});
2515
+ float backdropReveal = smoothstep(${ZOOM_REVEAL_CONFIG.backdropRevealStart}, ${ZOOM_REVEAL_CONFIG.backdropRevealEnd}, mappedZoom);
2516
+
2517
+ vColor = color * uBackdropEnergy * extinction * horizonFade * scintillation * uBackdropGain * backdropReveal;
1214
2518
 
1215
2519
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1216
2520
  gl_Position = smartProject(mvPosition);
1217
2521
  vScreenPos = gl_Position.xy / gl_Position.w;
1218
2522
 
1219
- float zoomScale = pow(uScale, 0.5);
2523
+ float zoomScale = pow(max(uScale, 0.0001), uBackdropSizeExp);
1220
2524
  float perceptualSize = pow(size, 0.55);
1221
- gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
2525
+ float sizeGain = mix(0.78, 1.0, uBackdropGain);
2526
+ gl_PointSize = clamp(perceptualSize * zoomScale * sizeGain * 0.5 * pixelRatio * (800.0 / length(mvPosition.xyz)) * horizonFade, 0.5, 20.0);
1222
2527
  }
1223
2528
  `,
1224
2529
  fragmentShader: `
@@ -1242,27 +2547,34 @@ function createEngine({
1242
2547
  transparent: true,
1243
2548
  depthWrite: false,
1244
2549
  depthTest: true,
1245
- blending: THREE5.AdditiveBlending
2550
+ blending: THREE6.AdditiveBlending
1246
2551
  });
1247
- const points = new THREE5.Points(geometry, material);
2552
+ backdropStarsMaterial = material;
2553
+ const points = new THREE6.Points(geometry, material);
1248
2554
  points.frustumCulled = false;
1249
2555
  backdropGroup.add(points);
1250
2556
  }
2557
+ createSkyBackground();
1251
2558
  createGround();
1252
2559
  createAtmosphere();
2560
+ createMoon();
2561
+ createSun();
2562
+ createMilkyWay();
1253
2563
  createBackdropStars();
1254
- const raycaster = new THREE5.Raycaster();
2564
+ const raycaster = new THREE6.Raycaster();
1255
2565
  raycaster.params.Points.threshold = 5;
1256
- new THREE5.Vector2();
1257
- const root = new THREE5.Group();
2566
+ new THREE6.Vector2();
2567
+ const root = new THREE6.Group();
1258
2568
  scene.add(root);
1259
2569
  const nodeById = /* @__PURE__ */ new Map();
1260
2570
  const starIndexToId = [];
2571
+ const starIdToIndex = /* @__PURE__ */ new Map();
1261
2572
  const dynamicLabels = [];
2573
+ const labelManager = new LabelManager();
1262
2574
  const hoverLabelMat = createSmartMaterial({
1263
2575
  uniforms: {
1264
2576
  uMap: { value: null },
1265
- uSize: { value: new THREE5.Vector2(1, 1) },
2577
+ uSize: { value: new THREE6.Vector2(1, 1) },
1266
2578
  uAlpha: { value: 0 },
1267
2579
  uAngle: { value: 0 }
1268
2580
  },
@@ -1300,7 +2612,7 @@ function createEngine({
1300
2612
  depthTest: false
1301
2613
  // Always on top of stars
1302
2614
  });
1303
- const hoverLabelMesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), hoverLabelMat);
2615
+ const hoverLabelMesh = new THREE6.Mesh(new THREE6.PlaneGeometry(1, 1), hoverLabelMat);
1304
2616
  hoverLabelMesh.visible = false;
1305
2617
  hoverLabelMesh.renderOrder = 999;
1306
2618
  hoverLabelMesh.frustumCulled = false;
@@ -1324,7 +2636,9 @@ function createEngine({
1324
2636
  }
1325
2637
  nodeById.clear();
1326
2638
  starIndexToId.length = 0;
2639
+ starIdToIndex.clear();
1327
2640
  dynamicLabels.length = 0;
2641
+ labelManager.clear();
1328
2642
  constellationLines = null;
1329
2643
  boundaryLines = null;
1330
2644
  starPoints = null;
@@ -1346,49 +2660,132 @@ function createEngine({
1346
2660
  ctx.textAlign = "center";
1347
2661
  ctx.textBaseline = "middle";
1348
2662
  ctx.fillText(text, w / 2, h / 2);
1349
- const tex = new THREE5.CanvasTexture(canvas);
1350
- tex.minFilter = THREE5.LinearFilter;
2663
+ const tex = new THREE6.CanvasTexture(canvas);
2664
+ tex.minFilter = THREE6.LinearFilter;
1351
2665
  return { tex, aspect: w / h };
1352
2666
  }
1353
2667
  function getPosition(n) {
1354
2668
  if (currentConfig?.arrangement) {
1355
2669
  const arr = currentConfig.arrangement[n.id];
1356
- if (arr) {
1357
- if (arr.position[2] === 0) {
1358
- const x = arr.position[0];
1359
- const y = arr.position[1];
2670
+ if (arr?.position) {
2671
+ const [px, py, pz] = arr.position;
2672
+ if (pz === 0) {
1360
2673
  const radius = currentConfig.layout?.radius ?? 2e3;
1361
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
1362
- const phi = Math.atan2(y, x);
1363
- const theta = r_norm * (Math.PI / 2);
1364
- return new THREE5.Vector3(
1365
- Math.sin(theta) * Math.cos(phi),
1366
- Math.cos(theta),
1367
- Math.sin(theta) * Math.sin(phi)
1368
- ).multiplyScalar(radius);
2674
+ const len3d = Math.sqrt(px * px + py * py);
2675
+ if (len3d < radius * 0.99) {
2676
+ const r_norm = Math.min(1, len3d / radius);
2677
+ const phi = Math.atan2(py, px);
2678
+ const theta = r_norm * (Math.PI / 2);
2679
+ return new THREE6.Vector3(
2680
+ Math.sin(theta) * Math.cos(phi),
2681
+ Math.cos(theta),
2682
+ Math.sin(theta) * Math.sin(phi)
2683
+ ).multiplyScalar(radius);
2684
+ }
1369
2685
  }
1370
- return new THREE5.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2686
+ return new THREE6.Vector3(px, py, pz);
1371
2687
  }
1372
2688
  }
1373
- return new THREE5.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
2689
+ return new THREE6.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
1374
2690
  }
1375
2691
  function getBoundaryPoint(angle, t, radius) {
1376
2692
  const y = 0.05 + t * (1 - 0.05);
1377
2693
  const rY = Math.sqrt(1 - y * y);
1378
2694
  const x = Math.cos(angle) * rY;
1379
2695
  const z = Math.sin(angle) * rY;
1380
- return new THREE5.Vector3(x, y, z).multiplyScalar(radius);
2696
+ return new THREE6.Vector3(x, y, z).multiplyScalar(radius);
2697
+ }
2698
+ function updateChapterLabelAnchors() {
2699
+ if (!starPoints) return;
2700
+ const attr = starPoints.geometry.attributes.position;
2701
+ if (!attr) return;
2702
+ const cameraUpWorld = new THREE6.Vector3(0, 1, 0).applyQuaternion(camera.quaternion).normalize();
2703
+ const cameraRightWorld = new THREE6.Vector3(1, 0, 0).applyQuaternion(camera.quaternion).normalize();
2704
+ for (const item of dynamicLabels) {
2705
+ if (item.node.level !== 3) continue;
2706
+ const idx = starIdToIndex.get(item.node.id);
2707
+ if (idx === void 0) continue;
2708
+ const starPos = new THREE6.Vector3(attr.getX(idx), attr.getY(idx), attr.getZ(idx));
2709
+ const normal = starPos.clone().normalize();
2710
+ const tangent = cameraUpWorld.clone().sub(normal.clone().multiplyScalar(cameraUpWorld.dot(normal)));
2711
+ if (tangent.lengthSq() < 1e-6) {
2712
+ tangent.copy(cameraRightWorld).sub(normal.clone().multiplyScalar(cameraRightWorld.dot(normal)));
2713
+ }
2714
+ if (tangent.lengthSq() < 1e-6) continue;
2715
+ tangent.normalize();
2716
+ const starNorm = item.chapterStarSizeNorm ?? 0.5;
2717
+ const baseSize = item.chapterStarBaseSize ?? 3.5;
2718
+ const altitude = normal.y;
2719
+ const horizonFade = THREE6.MathUtils.smoothstep(altitude, -0.1, 0.05);
2720
+ const mvPos = starPos.clone().applyMatrix4(camera.matrixWorldInverse);
2721
+ const dist = Math.max(1, mvPos.length());
2722
+ const perceptualSize = Math.pow(baseSize, 0.7);
2723
+ const sizeBoost = 1 + Math.pow(baseSize, 0.5) * 0.08;
2724
+ const pointSize = THREE6.MathUtils.clamp(
2725
+ perceptualSize * sizeBoost * 20 * globalUniforms.uScale.value * renderer.getPixelRatio() * (2e3 / dist) * horizonFade,
2726
+ 1,
2727
+ 600
2728
+ );
2729
+ item.chapterGlowRadiusPx = pointSize * 0.6;
2730
+ const viewportH = Math.max(1, renderer.domElement.clientHeight);
2731
+ const fovRad = state.fov * Math.PI / 180;
2732
+ const worldPerPixel = 2 * dist * Math.tan(fovRad * 0.5) / viewportH;
2733
+ let labelHalfDiagPx = 18;
2734
+ const mat = item.obj.material;
2735
+ if (mat instanceof THREE6.ShaderMaterial && mat.uniforms?.uSize?.value instanceof THREE6.Vector2) {
2736
+ const uAlpha = typeof mat.uniforms.uAlpha?.value === "number" ? mat.uniforms.uAlpha.value : 0;
2737
+ const revealT = THREE6.MathUtils.smoothstep(uAlpha, 0, 1);
2738
+ const revealScale = 0.82 + 0.28 * revealT;
2739
+ const fadeOutScale = 1 + (1 - revealT) * 0.06;
2740
+ const zoomTextBoost = THREE6.MathUtils.lerp(1.4, 0.55, THREE6.MathUtils.smoothstep(state.fov, 8, 46));
2741
+ const starTextBoost = THREE6.MathUtils.lerp(0.9, 1.35, starNorm);
2742
+ const scaleMul = zoomTextBoost * starTextBoost * revealScale * fadeOutScale;
2743
+ const uSize = mat.uniforms.uSize.value;
2744
+ const targetX = item.initialScale.x * scaleMul;
2745
+ const targetY = item.initialScale.y * scaleMul;
2746
+ uSize.x = THREE6.MathUtils.lerp(uSize.x, targetX, 0.2);
2747
+ uSize.y = THREE6.MathUtils.lerp(uSize.y, targetY, 0.2);
2748
+ const size = mat.uniforms.uSize.value;
2749
+ const pixelH = size.y * viewportH * 0.8;
2750
+ const pixelW = size.x * viewportH * 0.8;
2751
+ labelHalfDiagPx = Math.max(6, Math.max(pixelH, pixelW * 0.45) * 0.5);
2752
+ }
2753
+ const edgeMarginPx = THREE6.MathUtils.lerp(1, 3, starNorm);
2754
+ const requiredPx = item.chapterGlowRadiusPx + edgeMarginPx + labelHalfDiagPx;
2755
+ const zoomPush = 1 + (1 - THREE6.MathUtils.smoothstep(state.fov, 8, 30)) * 0.8;
2756
+ const starPush = THREE6.MathUtils.lerp(0.95, 1.2, starNorm);
2757
+ const offset = THREE6.MathUtils.clamp(requiredPx * worldPerPixel * zoomPush * starPush, 3, 76);
2758
+ item.obj.position.copy(starPos);
2759
+ item.obj.position.addScaledVector(tangent, offset);
2760
+ item.obj.position.addScaledVector(normal, 2.5);
2761
+ item.chapterStarWorldPos = starPos.clone();
2762
+ }
2763
+ for (const item of dynamicLabels) {
2764
+ const level = item.node.level;
2765
+ if (level !== 2 && level !== 2.5) continue;
2766
+ const mat = item.obj.material;
2767
+ if (!(mat instanceof THREE6.ShaderMaterial) || !(mat.uniforms?.uSize?.value instanceof THREE6.Vector2)) continue;
2768
+ const entryFov = 22;
2769
+ const zoomBoost = THREE6.MathUtils.lerp(1.3, 0.5, THREE6.MathUtils.smoothstep(state.fov, 8, entryFov));
2770
+ const uAlpha = typeof mat.uniforms.uAlpha?.value === "number" ? mat.uniforms.uAlpha.value : 0;
2771
+ const revealT = THREE6.MathUtils.smoothstep(uAlpha, 0, 1);
2772
+ const revealScale = 0.82 + 0.28 * revealT;
2773
+ const scaleMul = zoomBoost * revealScale;
2774
+ const uSize = mat.uniforms.uSize.value;
2775
+ uSize.x = THREE6.MathUtils.lerp(uSize.x, item.initialScale.x * scaleMul, 0.2);
2776
+ uSize.y = THREE6.MathUtils.lerp(uSize.y, item.initialScale.y * scaleMul, 0.2);
2777
+ }
1381
2778
  }
1382
2779
  function buildFromModel(model, cfg) {
1383
2780
  clearRoot();
1384
2781
  bookIdToIndex.clear();
1385
2782
  testamentToIndex.clear();
1386
2783
  divisionToIndex.clear();
1387
- scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5.Color(cfg.background) : new THREE5.Color(0);
2784
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE6.Color(cfg.background) : null;
1388
2785
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1389
2786
  const laidOut = computeLayoutPositions(model, layoutCfg);
1390
2787
  const divisionPositions = /* @__PURE__ */ new Map();
1391
- if (cfg.arrangement) {
2788
+ {
1392
2789
  const divMap = /* @__PURE__ */ new Map();
1393
2790
  for (const n of laidOut.nodes) {
1394
2791
  if (n.level === 2 && n.parent) {
@@ -1398,7 +2795,7 @@ function createEngine({
1398
2795
  }
1399
2796
  }
1400
2797
  for (const [divId, books] of divMap.entries()) {
1401
- const centroid = new THREE5.Vector3();
2798
+ const centroid = new THREE6.Vector3();
1402
2799
  let count = 0;
1403
2800
  for (const b of books) {
1404
2801
  const p = getPosition(b);
@@ -1419,20 +2816,26 @@ function createEngine({
1419
2816
  const starChapterIndices = [];
1420
2817
  const starTestamentIndices = [];
1421
2818
  const starDivisionIndices = [];
2819
+ const starRevealThresholds = [];
2820
+ const chapterLineCutById = /* @__PURE__ */ new Map();
2821
+ const chapterStarSizeById = /* @__PURE__ */ new Map();
2822
+ const chapterWeightNormById = /* @__PURE__ */ new Map();
2823
+ let minChapterStarSize = Infinity;
2824
+ let maxChapterStarSize = -Infinity;
1422
2825
  const SPECTRAL_COLORS = [
1423
- new THREE5.Color(14544639),
2826
+ new THREE6.Color(14544639),
1424
2827
  // O - Blueish White
1425
- new THREE5.Color(15660287),
2828
+ new THREE6.Color(15660287),
1426
2829
  // B - White
1427
- new THREE5.Color(16317695),
2830
+ new THREE6.Color(16317695),
1428
2831
  // A - White
1429
- new THREE5.Color(16777208),
2832
+ new THREE6.Color(16777208),
1430
2833
  // F - White
1431
- new THREE5.Color(16775406),
2834
+ new THREE6.Color(16775406),
1432
2835
  // G - Yellowish White
1433
- new THREE5.Color(16773085),
2836
+ new THREE6.Color(16773085),
1434
2837
  // K - Pale Orange
1435
- new THREE5.Color(16771788)
2838
+ new THREE6.Color(16771788)
1436
2839
  // M - Light Orange
1437
2840
  ];
1438
2841
  let minWeight = Infinity;
@@ -1450,17 +2853,59 @@ function createEngine({
1450
2853
  } else if (minWeight === maxWeight) {
1451
2854
  maxWeight = minWeight + 1;
1452
2855
  }
2856
+ {
2857
+ const pctCap = THREE6.MathUtils.clamp(cfg.starSizeWeightPercentile ?? 0.95, 0.5, 1);
2858
+ const allWeights = [];
2859
+ for (const n of laidOut.nodes) {
2860
+ if (n.level === 3 && typeof n.weight === "number") allWeights.push(n.weight);
2861
+ }
2862
+ allWeights.sort((a, b) => a - b);
2863
+ const capIdx = Math.min(Math.floor(pctCap * allWeights.length), allWeights.length - 1);
2864
+ const cappedMax = allWeights[capIdx];
2865
+ if (cappedMax !== void 0 && cappedMax > minWeight) maxWeight = cappedMax;
2866
+ }
1453
2867
  for (const n of laidOut.nodes) {
1454
2868
  if (n.level === 3) {
1455
- const p = getPosition(n);
1456
- starPositions.push(p.x, p.y, p.z);
1457
- starIndexToId.push(n.id);
1458
2869
  let baseSize = 3.5;
2870
+ let weightNorm = 0;
1459
2871
  if (typeof n.weight === "number") {
1460
- const t = (n.weight - minWeight) / (maxWeight - minWeight);
1461
- baseSize = 0.1 + Math.pow(t, 0.5) * 11.9;
2872
+ weightNorm = THREE6.MathUtils.clamp((n.weight - minWeight) / (maxWeight - minWeight), 0, 1);
2873
+ const sizeExp = cfg.starSizeExponent ?? 4;
2874
+ const sizeScale = cfg.starSizeScale ?? 6;
2875
+ baseSize = Math.pow(weightNorm, sizeExp) * 22 * sizeScale;
1462
2876
  }
2877
+ chapterStarSizeById.set(n.id, baseSize);
2878
+ chapterWeightNormById.set(n.id, weightNorm);
2879
+ minChapterStarSize = Math.min(minChapterStarSize, baseSize);
2880
+ maxChapterStarSize = Math.max(maxChapterStarSize, baseSize);
2881
+ }
2882
+ }
2883
+ if (!Number.isFinite(minChapterStarSize)) {
2884
+ minChapterStarSize = 1;
2885
+ maxChapterStarSize = 2;
2886
+ } else if (minChapterStarSize === maxChapterStarSize) {
2887
+ maxChapterStarSize = minChapterStarSize + 1;
2888
+ }
2889
+ for (const n of laidOut.nodes) {
2890
+ if (n.level === 3) {
2891
+ const p = getPosition(n);
2892
+ starPositions.push(p.x, p.y, p.z);
2893
+ starIdToIndex.set(n.id, starIndexToId.length);
2894
+ starIndexToId.push(n.id);
2895
+ const baseSize = chapterStarSizeById.get(n.id) ?? 3.5;
1463
2896
  starSizes.push(baseSize);
2897
+ {
2898
+ const wn = chapterWeightNormById.get(n.id) ?? 0;
2899
+ starRevealThresholds.push(THREE6.MathUtils.lerp(
2900
+ -ZOOM_REVEAL_CONFIG.chapterFeather,
2901
+ ZOOM_REVEAL_CONFIG.chapterRevealMax,
2902
+ 1 - wn
2903
+ ));
2904
+ }
2905
+ chapterLineCutById.set(
2906
+ n.id,
2907
+ THREE6.MathUtils.clamp(2.5 + baseSize * 0.45, 3, 40)
2908
+ );
1464
2909
  const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
1465
2910
  const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
1466
2911
  starColors.push(c.r, c.g, c.b);
@@ -1511,8 +2956,11 @@ function createEngine({
1511
2956
  let baseScale = 0.05;
1512
2957
  if (n.level === 1) baseScale = 0.08;
1513
2958
  else if (n.level === 2) baseScale = 0.04;
1514
- else if (n.level === 3) baseScale = 0.03;
1515
- const size = new THREE5.Vector2(baseScale * texRes.aspect, baseScale);
2959
+ else if (n.level === 3) {
2960
+ const wn2 = chapterWeightNormById.get(n.id) ?? 0;
2961
+ baseScale = THREE6.MathUtils.lerp(0.019, 0.039, wn2);
2962
+ }
2963
+ const size = new THREE6.Vector2(baseScale * texRes.aspect, baseScale);
1516
2964
  const mat = createSmartMaterial({
1517
2965
  uniforms: {
1518
2966
  uMap: { value: texRes.tex },
@@ -1551,39 +2999,60 @@ function createEngine({
1551
2999
  `,
1552
3000
  transparent: true,
1553
3001
  depthWrite: false,
1554
- depthTest: true
3002
+ depthTest: n.level === 3 ? false : true
1555
3003
  });
1556
- const mesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), mat);
3004
+ const mesh = new THREE6.Mesh(new THREE6.PlaneGeometry(1, 1), mat);
1557
3005
  let p = getPosition(n);
1558
3006
  if (n.level === 1) {
1559
- if (divisionPositions.has(n.id)) {
1560
- p.copy(divisionPositions.get(n.id));
3007
+ if (cfg.arrangement?.[n.id]) {
3008
+ const arr = cfg.arrangement[n.id];
3009
+ p.set(arr.position[0], arr.position[1], arr.position[2]);
3010
+ } else {
3011
+ if (divisionPositions.has(n.id)) {
3012
+ p.copy(divisionPositions.get(n.id));
3013
+ }
3014
+ const r = layoutCfg.radius * 0.95;
3015
+ const angle = Math.atan2(p.z, p.x);
3016
+ p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1561
3017
  }
1562
- const r = layoutCfg.radius * 0.95;
1563
- const angle = Math.atan2(p.z, p.x);
1564
- p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1565
3018
  } else if (n.level === 3) {
1566
- p.y += 30;
1567
- p.multiplyScalar(1.001);
3019
+ const starSize = chapterStarSizeById.get(n.id) ?? 3.5;
3020
+ const starNorm = THREE6.MathUtils.clamp(
3021
+ (starSize - minChapterStarSize) / (maxChapterStarSize - minChapterStarSize),
3022
+ 0,
3023
+ 1
3024
+ );
3025
+ const radialOffset = THREE6.MathUtils.lerp(16, 46, starNorm);
3026
+ p.addScaledVector(p.clone().normalize(), radialOffset);
1568
3027
  }
1569
3028
  mesh.position.set(p.x, p.y, p.z);
1570
3029
  mesh.scale.set(size.x, size.y, 1);
1571
3030
  mesh.frustumCulled = false;
1572
3031
  mesh.userData = { id: n.id };
1573
3032
  root.add(mesh);
1574
- dynamicLabels.push({ obj: mesh, node: n, initialScale: size.clone() });
3033
+ const wn = n.level === 3 ? chapterWeightNormById.get(n.id) ?? 0 : 0;
3034
+ const chapterMaxFovBias = n.level === 3 ? THREE6.MathUtils.lerp(-4, 8, wn) : 0;
3035
+ dynamicLabels.push({
3036
+ obj: mesh,
3037
+ node: n,
3038
+ initialScale: size.clone(),
3039
+ maxFovBias: chapterMaxFovBias,
3040
+ chapterStarSizeNorm: n.level === 3 ? wn : void 0,
3041
+ chapterStarBaseSize: n.level === 3 ? chapterStarSizeById.get(n.id) ?? 3.5 : void 0
3042
+ });
1575
3043
  }
1576
3044
  }
1577
3045
  }
1578
- const starGeo = new THREE5.BufferGeometry();
1579
- starGeo.setAttribute("position", new THREE5.Float32BufferAttribute(starPositions, 3));
1580
- starGeo.setAttribute("size", new THREE5.Float32BufferAttribute(starSizes, 1));
1581
- starGeo.setAttribute("color", new THREE5.Float32BufferAttribute(starColors, 3));
1582
- starGeo.setAttribute("phase", new THREE5.Float32BufferAttribute(starPhases, 1));
1583
- starGeo.setAttribute("bookIndex", new THREE5.Float32BufferAttribute(starBookIndices, 1));
1584
- starGeo.setAttribute("chapterIndex", new THREE5.Float32BufferAttribute(starChapterIndices, 1));
1585
- starGeo.setAttribute("testamentIndex", new THREE5.Float32BufferAttribute(starTestamentIndices, 1));
1586
- starGeo.setAttribute("divisionIndex", new THREE5.Float32BufferAttribute(starDivisionIndices, 1));
3046
+ const starGeo = new THREE6.BufferGeometry();
3047
+ starGeo.setAttribute("position", new THREE6.Float32BufferAttribute(starPositions, 3));
3048
+ starGeo.setAttribute("size", new THREE6.Float32BufferAttribute(starSizes, 1));
3049
+ starGeo.setAttribute("color", new THREE6.Float32BufferAttribute(starColors, 3));
3050
+ starGeo.setAttribute("phase", new THREE6.Float32BufferAttribute(starPhases, 1));
3051
+ starGeo.setAttribute("bookIndex", new THREE6.Float32BufferAttribute(starBookIndices, 1));
3052
+ starGeo.setAttribute("chapterIndex", new THREE6.Float32BufferAttribute(starChapterIndices, 1));
3053
+ starGeo.setAttribute("testamentIndex", new THREE6.Float32BufferAttribute(starTestamentIndices, 1));
3054
+ starGeo.setAttribute("divisionIndex", new THREE6.Float32BufferAttribute(starDivisionIndices, 1));
3055
+ starGeo.setAttribute("revealThreshold", new THREE6.Float32BufferAttribute(starRevealThresholds, 1));
1587
3056
  const starMat = createSmartMaterial({
1588
3057
  uniforms: {
1589
3058
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1592,7 +3061,7 @@ function createEngine({
1592
3061
  uActiveBookIndex: { value: -1 },
1593
3062
  uOrderRevealStrength: { value: 0 },
1594
3063
  uGlobalDimFactor: { value: ORDER_REVEAL_CONFIG.globalDim },
1595
- uPulseParams: { value: new THREE5.Vector3(
3064
+ uPulseParams: { value: new THREE6.Vector3(
1596
3065
  ORDER_REVEAL_CONFIG.pulseDuration,
1597
3066
  ORDER_REVEAL_CONFIG.delayPerChapter,
1598
3067
  ORDER_REVEAL_CONFIG.pulseAmplitude
@@ -1601,18 +3070,22 @@ function createEngine({
1601
3070
  uFilterDivisionIndex: { value: -1 },
1602
3071
  uFilterBookIndex: { value: -1 },
1603
3072
  uFilterStrength: { value: 0 },
1604
- uFilterDimFactor: { value: 0.08 }
3073
+ uFilterDimFactor: { value: 0.08 },
3074
+ uRevealZoom: { value: 0 }
1605
3075
  },
1606
3076
  vertexShaderBody: `
1607
- attribute float size;
1608
- attribute vec3 color;
3077
+ attribute float size;
3078
+ attribute vec3 color;
1609
3079
  attribute float phase;
1610
3080
  attribute float bookIndex;
1611
3081
  attribute float chapterIndex;
1612
3082
  attribute float testamentIndex;
1613
3083
  attribute float divisionIndex;
3084
+ attribute float revealThreshold;
1614
3085
 
1615
3086
  varying vec3 vColor;
3087
+ varying float vSize;
3088
+ varying float vReveal;
1616
3089
  uniform float pixelRatio;
1617
3090
 
1618
3091
  uniform float uTime;
@@ -1629,6 +3102,7 @@ function createEngine({
1629
3102
  uniform float uFilterBookIndex;
1630
3103
  uniform float uFilterStrength;
1631
3104
  uniform float uFilterDimFactor;
3105
+ uniform float uRevealZoom;
1632
3106
 
1633
3107
  void main() {
1634
3108
  vec3 nPos = normalize(position);
@@ -1685,41 +3159,166 @@ function createEngine({
1685
3159
  gl_Position = smartProject(mvPosition);
1686
3160
  vScreenPos = gl_Position.xy / gl_Position.w;
1687
3161
 
1688
- float sizeBoost = 1.0 + activePulse * 0.8;
1689
- float perceptualSize = pow(size, 0.55);
1690
- gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
3162
+ float sizeBoost = 1.0 + activePulse * 0.15;
3163
+ // pow(size, 0.7) is gentler compression than 0.55 \u2014 preserves more of
3164
+ // the aggressive JS curve so large stars stay visually dominant.
3165
+ float perceptualSize = pow(size, 0.7);
3166
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 20.0) * uScale * pixelRatio * (2000.0 / length(mvPosition.xyz)) * horizonFade, 1.0, 600.0);
3167
+ vSize = gl_PointSize;
3168
+
3169
+ // Zoom-based reveal: faint stars hide at wide FOV, fade in as user zooms.
3170
+ // Exponent and feather baked from ZOOM_REVEAL_CONFIG at startup.
3171
+ float mappedZoom = pow(uRevealZoom, ${ZOOM_REVEAL_CONFIG.zoomCurveExp});
3172
+ vReveal = smoothstep(revealThreshold, revealThreshold + ${ZOOM_REVEAL_CONFIG.chapterFeather}, mappedZoom);
1691
3173
  }
1692
3174
  `,
1693
3175
  fragmentShader: `
1694
- varying vec3 vColor;
1695
- void main() {
1696
- vec2 coord = gl_PointCoord - vec2(0.5);
1697
- float d = length(coord) * 2.0;
1698
- if (d > 1.0) discard;
1699
-
1700
- float alphaMask = getMaskAlpha();
1701
- if (alphaMask < 0.01) discard;
1702
-
1703
- // Stellarium-style dual-layer: sharp core + soft glow
1704
- float core = smoothstep(0.8, 0.4, d);
1705
- float glow = smoothstep(1.0, 0.0, d) * 0.08;
1706
- float k = core + glow;
3176
+ varying vec3 vColor;
3177
+ varying float vSize;
3178
+ varying float vReveal;
3179
+ void main() {
3180
+ vec2 coord = gl_PointCoord - vec2(0.5);
3181
+ float d = length(coord) * 2.0;
3182
+ if (d > 1.0) discard;
3183
+
3184
+ float alphaMask = getMaskAlpha();
3185
+ if (alphaMask < 0.01) discard;
3186
+
3187
+ // --- Multi-layer Gaussian star model ---
3188
+ // Tight white-hot core
3189
+ float core = exp(-d * d * 9.0);
3190
+ // Broader coloured inner halo
3191
+ float innerGlow = exp(-d * d * 3.0) * 0.45;
3192
+ // Wide faint bloom that fades smoothly to the disc edge
3193
+ float outerBloom = max(0.0, 1.0 - d * d) * 0.10;
3194
+
3195
+ float k = core + innerGlow + outerBloom;
1707
3196
 
1708
- // White-hot core blending into coloured halo
1709
- vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1710
- gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
3197
+ // White-hot centre \u2192 spectral colour at the halo
3198
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.88);
3199
+
3200
+ // --- Size-dependent diffraction spikes ---
3201
+ // Only appear on larger (brighter) stars, matching real optics.
3202
+ float spikeFactor = smoothstep(10.0, 24.0, vSize);
3203
+ float spikeH = exp(-coord.y * coord.y * 180.0) * exp(-abs(coord.x) * 6.0);
3204
+ float spikeV = exp(-coord.x * coord.x * 180.0) * exp(-abs(coord.y) * 6.0);
3205
+ float spikes = (spikeH + spikeV) * 0.18 * spikeFactor;
3206
+
3207
+ // vReveal drives the additive contribution (AdditiveBlending uses SRC_ALPHA).
3208
+ gl_FragColor = vec4(finalColor * (k + spikes) * alphaMask, vReveal);
1711
3209
  }
1712
3210
  `,
1713
3211
  transparent: true,
1714
3212
  depthWrite: false,
1715
3213
  depthTest: true,
1716
- blending: THREE5.AdditiveBlending
3214
+ blending: THREE6.AdditiveBlending
1717
3215
  });
1718
- starPoints = new THREE5.Points(starGeo, starMat);
3216
+ starPoints = new THREE6.Points(starGeo, starMat);
1719
3217
  starPoints.frustumCulled = false;
1720
3218
  root.add(starPoints);
1721
3219
  const linePoints = [];
3220
+ const lineWeights = [];
3221
+ const seenEdges = /* @__PURE__ */ new Set();
1722
3222
  const bookMap = /* @__PURE__ */ new Map();
3223
+ const parseBookKeyFromChapterId = (id) => {
3224
+ if (!id) return null;
3225
+ const parts = id.split(":");
3226
+ if (parts.length < 3 || parts[0] !== "C") return null;
3227
+ return parts[1] || null;
3228
+ };
3229
+ const weightScaleFromLabel = (weight) => {
3230
+ if (weight === "thin") return 0.65;
3231
+ if (weight === "bold") return 1.6;
3232
+ return 1;
3233
+ };
3234
+ const edgeKey = (aNodeId, bNodeId) => aNodeId < bNodeId ? `${aNodeId}|${bNodeId}` : `${bNodeId}|${aNodeId}`;
3235
+ const addTruncatedSegment = (aNodeId, bNodeId, weightScale) => {
3236
+ if (aNodeId === bNodeId) return;
3237
+ const k = edgeKey(aNodeId, bNodeId);
3238
+ if (seenEdges.has(k)) return;
3239
+ seenEdges.add(k);
3240
+ const aNode = nodeById.get(aNodeId);
3241
+ const bNode = nodeById.get(bNodeId);
3242
+ if (!aNode || !bNode) return;
3243
+ const p1 = getPosition(aNode);
3244
+ const p2 = getPosition(bNode);
3245
+ const dir = new THREE6.Vector3().subVectors(p2, p1);
3246
+ const len = dir.length();
3247
+ if (len < 1e-3) return;
3248
+ dir.divideScalar(len);
3249
+ let cutA = chapterLineCutById.get(aNodeId) ?? 4;
3250
+ let cutB = chapterLineCutById.get(bNodeId) ?? 4;
3251
+ const maxTotalCut = len * 0.8;
3252
+ const totalCut = cutA + cutB;
3253
+ if (totalCut > maxTotalCut && totalCut > 0) {
3254
+ const scale = maxTotalCut / totalCut;
3255
+ cutA *= scale;
3256
+ cutB *= scale;
3257
+ }
3258
+ const a = p1.clone().addScaledVector(dir, cutA);
3259
+ const b = p2.clone().addScaledVector(dir, -cutB);
3260
+ linePoints.push(a.x, a.y, a.z);
3261
+ linePoints.push(b.x, b.y, b.z);
3262
+ lineWeights.push(weightScale);
3263
+ };
3264
+ const customBooks = /* @__PURE__ */ new Set();
3265
+ const rawConstellations = cfg.constellations && Array.isArray(cfg.constellations.constellations) ? cfg.constellations.constellations : [];
3266
+ for (const c of rawConstellations) {
3267
+ const linePaths = Array.isArray(c?.linePaths) ? c.linePaths : [];
3268
+ const lineSegments = Array.isArray(c?.lineSegments) ? c.lineSegments : [];
3269
+ if (linePaths.length === 0 && lineSegments.length === 0) continue;
3270
+ const anchorBookKey = parseBookKeyFromChapterId(c?.anchors?.[0]);
3271
+ if (anchorBookKey) customBooks.add(anchorBookKey);
3272
+ for (const segDef of lineSegments) {
3273
+ let from;
3274
+ let to;
3275
+ let weightLabel;
3276
+ if (Array.isArray(segDef)) {
3277
+ const raw = segDef;
3278
+ if (typeof raw[0] === "string" && (raw[0] === "thin" || raw[0] === "bold" || raw[0] === "normal")) {
3279
+ weightLabel = raw[0];
3280
+ from = typeof raw[1] === "string" ? raw[1] : void 0;
3281
+ to = typeof raw[2] === "string" ? raw[2] : void 0;
3282
+ } else {
3283
+ from = typeof raw[0] === "string" ? raw[0] : void 0;
3284
+ to = typeof raw[1] === "string" ? raw[1] : void 0;
3285
+ }
3286
+ } else if (segDef) {
3287
+ from = typeof segDef.from === "string" ? segDef.from : void 0;
3288
+ to = typeof segDef.to === "string" ? segDef.to : void 0;
3289
+ weightLabel = typeof segDef.weight === "string" ? segDef.weight : void 0;
3290
+ }
3291
+ if (!from || !to) continue;
3292
+ const k1 = parseBookKeyFromChapterId(from);
3293
+ const k2 = parseBookKeyFromChapterId(to);
3294
+ if (k1) customBooks.add(k1);
3295
+ if (k2) customBooks.add(k2);
3296
+ addTruncatedSegment(from, to, weightScaleFromLabel(weightLabel));
3297
+ }
3298
+ for (const pathDef of linePaths) {
3299
+ let nodes = [];
3300
+ let weightLabel = void 0;
3301
+ if (Array.isArray(pathDef)) {
3302
+ const raw = pathDef;
3303
+ if (typeof raw[0] === "string" && (raw[0] === "thin" || raw[0] === "bold" || raw[0] === "normal")) {
3304
+ weightLabel = raw[0];
3305
+ nodes = raw.slice(1).filter((v) => typeof v === "string");
3306
+ } else {
3307
+ nodes = raw.filter((v) => typeof v === "string");
3308
+ }
3309
+ } else if (pathDef && Array.isArray(pathDef.nodes)) {
3310
+ nodes = pathDef.nodes.filter((v) => typeof v === "string");
3311
+ weightLabel = typeof pathDef.weight === "string" ? pathDef.weight : void 0;
3312
+ }
3313
+ if (nodes.length < 2) continue;
3314
+ const inferredBookKey = parseBookKeyFromChapterId(nodes[0]);
3315
+ if (inferredBookKey) customBooks.add(inferredBookKey);
3316
+ const w = weightScaleFromLabel(weightLabel);
3317
+ for (let i = 0; i < nodes.length - 1; i++) {
3318
+ addTruncatedSegment(nodes[i], nodes[i + 1], w);
3319
+ }
3320
+ }
3321
+ }
1723
3322
  for (const n of laidOut.nodes) {
1724
3323
  if (n.level === 3 && n.parent) {
1725
3324
  const list = bookMap.get(n.parent) ?? [];
@@ -1730,24 +3329,27 @@ function createEngine({
1730
3329
  for (const chapters of bookMap.values()) {
1731
3330
  chapters.sort((a, b) => (a.meta?.chapter || 0) - (b.meta?.chapter || 0));
1732
3331
  if (chapters.length < 2) continue;
3332
+ const bookKey = chapters[0]?.meta?.bookKey ?? null;
3333
+ if (bookKey && customBooks.has(bookKey)) continue;
1733
3334
  for (let i = 0; i < chapters.length - 1; i++) {
1734
3335
  const c1 = chapters[i];
1735
3336
  const c2 = chapters[i + 1];
1736
3337
  if (!c1 || !c2) continue;
1737
- const p1 = getPosition(c1);
1738
- const p2 = getPosition(c2);
1739
- linePoints.push(p1.x, p1.y, p1.z);
1740
- linePoints.push(p2.x, p2.y, p2.z);
3338
+ addTruncatedSegment(c1.id, c2.id, 1);
1741
3339
  }
1742
3340
  }
1743
3341
  if (linePoints.length > 0) {
1744
3342
  const quadPositions = [];
1745
3343
  const quadUvs = [];
3344
+ const quadLineWeight = [];
3345
+ const quadSegmentIndex = [];
1746
3346
  const quadIndices = [];
1747
3347
  const lineWidth = 8;
3348
+ const segmentCount = linePoints.length / 6;
1748
3349
  for (let i = 0; i < linePoints.length; i += 6) {
1749
3350
  const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1750
3351
  const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
3352
+ const segIndex = i / 6;
1751
3353
  const dx = bx - ax, dy = by - ay, dz = bz - az;
1752
3354
  const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1753
3355
  if (len < 1e-3) continue;
@@ -1772,23 +3374,36 @@ function createEngine({
1772
3374
  quadUvs.push(1, -1);
1773
3375
  quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1774
3376
  quadUvs.push(1, 1);
3377
+ const w = lineWeights[segIndex] ?? 1;
3378
+ quadLineWeight.push(w, w, w, w);
3379
+ quadSegmentIndex.push(segIndex, segIndex, segIndex, segIndex);
1775
3380
  quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1776
3381
  }
1777
- const lineGeo = new THREE5.BufferGeometry();
1778
- lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(quadPositions, 3));
1779
- lineGeo.setAttribute("lineUv", new THREE5.Float32BufferAttribute(quadUvs, 2));
3382
+ const lineGeo = new THREE6.BufferGeometry();
3383
+ lineGeo.setAttribute("position", new THREE6.Float32BufferAttribute(quadPositions, 3));
3384
+ lineGeo.setAttribute("lineUv", new THREE6.Float32BufferAttribute(quadUvs, 2));
3385
+ lineGeo.setAttribute("lineWeight", new THREE6.Float32BufferAttribute(quadLineWeight, 1));
3386
+ lineGeo.setAttribute("segmentIndex", new THREE6.Float32BufferAttribute(quadSegmentIndex, 1));
1780
3387
  lineGeo.setIndex(quadIndices);
1781
3388
  const lineMat = createSmartMaterial({
1782
3389
  uniforms: {
1783
- color: { value: new THREE5.Color(11193599) },
3390
+ color: { value: new THREE6.Color(11193599) },
1784
3391
  uLineWidth: { value: 1.5 },
1785
- uGlowIntensity: { value: 0.3 }
3392
+ uGlowIntensity: { value: 0.3 },
3393
+ uReveal: { value: 0 },
3394
+ uSegmentCount: { value: Math.max(1, segmentCount) }
1786
3395
  },
1787
3396
  vertexShaderBody: `
1788
3397
  attribute vec2 lineUv;
3398
+ attribute float lineWeight;
3399
+ attribute float segmentIndex;
1789
3400
  varying vec2 vLineUv;
3401
+ varying float vLineWeight;
3402
+ varying float vSegmentIndex;
1790
3403
  void main() {
1791
3404
  vLineUv = lineUv;
3405
+ vLineWeight = lineWeight;
3406
+ vSegmentIndex = segmentIndex;
1792
3407
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1793
3408
  gl_Position = smartProject(mvPosition);
1794
3409
  vScreenPos = gl_Position.xy / gl_Position.w;
@@ -1798,32 +3413,53 @@ function createEngine({
1798
3413
  uniform vec3 color;
1799
3414
  uniform float uLineWidth;
1800
3415
  uniform float uGlowIntensity;
3416
+ uniform float uReveal;
3417
+ uniform float uSegmentCount;
1801
3418
  varying vec2 vLineUv;
3419
+ varying float vLineWeight;
3420
+ varying float vSegmentIndex;
1802
3421
  void main() {
1803
3422
  float alphaMask = getMaskAlpha();
1804
3423
  if (alphaMask < 0.01) discard;
1805
3424
 
3425
+ // Progressive line draw tuned closer to Stellarium feel:
3426
+ // - eased global reveal
3427
+ // - sequential segment staggering with slight overlap
3428
+ // - smooth growth of each segment endpoint
3429
+ float reveal = smoothstep(0.0, 1.0, uReveal);
3430
+ float segCount = max(uSegmentCount, 1.0);
3431
+ float segStart = vSegmentIndex / segCount;
3432
+ float segSpan = (1.25 / segCount) + 0.04;
3433
+ float localReveal = clamp((reveal - segStart) / segSpan, 0.0, 1.0);
3434
+ localReveal = smoothstep(0.0, 1.0, localReveal);
3435
+
3436
+ // Keep fragment only when x is before the animated endpoint.
3437
+ float endpointMask = 1.0 - smoothstep(localReveal - 0.03, localReveal + 0.02, vLineUv.x);
3438
+ // Fade in segment brightness as it begins drawing.
3439
+ float drawMask = endpointMask * smoothstep(0.0, 0.08, localReveal);
3440
+ if (drawMask < 0.001) discard;
3441
+
1806
3442
  float dist = abs(vLineUv.y);
1807
3443
 
1808
3444
  // Anti-aliased core line
1809
- float hw = uLineWidth * 0.05;
3445
+ float hw = (uLineWidth * vLineWeight) * 0.05;
1810
3446
  float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1811
3447
 
1812
3448
  // Soft glow extending outward
1813
- float glow = (1.0 - dist) * uGlowIntensity;
3449
+ float glow = (1.0 - dist) * uGlowIntensity * vLineWeight;
1814
3450
 
1815
3451
  float alpha = max(glow, base);
1816
3452
  if (alpha < 0.005) discard;
1817
3453
 
1818
- gl_FragColor = vec4(color, alpha * alphaMask);
3454
+ gl_FragColor = vec4(color, alpha * alphaMask * drawMask);
1819
3455
  }
1820
3456
  `,
1821
3457
  transparent: true,
1822
3458
  depthWrite: false,
1823
- blending: THREE5.AdditiveBlending,
1824
- side: THREE5.DoubleSide
3459
+ blending: THREE6.AdditiveBlending,
3460
+ side: THREE6.DoubleSide
1825
3461
  });
1826
- constellationLines = new THREE5.Mesh(lineGeo, lineMat);
3462
+ constellationLines = new THREE6.Mesh(lineGeo, lineMat);
1827
3463
  constellationLines.frustumCulled = false;
1828
3464
  root.add(constellationLines);
1829
3465
  }
@@ -1836,7 +3472,7 @@ function createEngine({
1836
3472
  if (groupList) {
1837
3473
  groupList.forEach((g, idx) => {
1838
3474
  const groupId = `G:${bookId}:${idx}`;
1839
- let p = new THREE5.Vector3();
3475
+ let p = new THREE6.Vector3();
1840
3476
  if (cfg.arrangement && cfg.arrangement[groupId]) {
1841
3477
  const arr = cfg.arrangement[groupId];
1842
3478
  p.set(arr.position[0], arr.position[1], arr.position[2]);
@@ -1855,7 +3491,7 @@ function createEngine({
1855
3491
  const texRes = createTextTexture(labelText, "#4fa4fa80");
1856
3492
  if (texRes) {
1857
3493
  const baseScale = 0.036;
1858
- const size = new THREE5.Vector2(baseScale * texRes.aspect, baseScale);
3494
+ const size = new THREE6.Vector2(baseScale * texRes.aspect, baseScale);
1859
3495
  const mat = createSmartMaterial({
1860
3496
  uniforms: {
1861
3497
  uMap: { value: texRes.tex },
@@ -1896,7 +3532,7 @@ function createEngine({
1896
3532
  depthWrite: false,
1897
3533
  depthTest: true
1898
3534
  });
1899
- const mesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), mat);
3535
+ const mesh = new THREE6.Mesh(new THREE6.PlaneGeometry(1, 1), mat);
1900
3536
  mesh.position.copy(p);
1901
3537
  mesh.scale.set(size.x, size.y, 1);
1902
3538
  mesh.frustumCulled = false;
@@ -1918,14 +3554,14 @@ function createEngine({
1918
3554
  const boundaries = laidOut.meta?.divisionBoundaries ?? [];
1919
3555
  if (boundaries.length > 0) {
1920
3556
  const boundaryMat = createSmartMaterial({
1921
- uniforms: { color: { value: new THREE5.Color(5601177) } },
3557
+ uniforms: { color: { value: new THREE6.Color(5601177) } },
1922
3558
  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; }`,
1923
3559
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
1924
3560
  transparent: true,
1925
3561
  depthWrite: false,
1926
- blending: THREE5.AdditiveBlending
3562
+ blending: THREE6.AdditiveBlending
1927
3563
  });
1928
- const boundaryGeo = new THREE5.BufferGeometry();
3564
+ const boundaryGeo = new THREE6.BufferGeometry();
1929
3565
  const bPoints = [];
1930
3566
  boundaries.forEach((angle) => {
1931
3567
  const steps = 32;
@@ -1938,8 +3574,8 @@ function createEngine({
1938
3574
  bPoints.push(p2.x, p2.y, p2.z);
1939
3575
  }
1940
3576
  });
1941
- boundaryGeo.setAttribute("position", new THREE5.Float32BufferAttribute(bPoints, 3));
1942
- boundaryLines = new THREE5.LineSegments(boundaryGeo, boundaryMat);
3577
+ boundaryGeo.setAttribute("position", new THREE6.Float32BufferAttribute(bPoints, 3));
3578
+ boundaryLines = new THREE6.LineSegments(boundaryGeo, boundaryMat);
1943
3579
  boundaryLines.frustumCulled = false;
1944
3580
  root.add(boundaryLines);
1945
3581
  }
@@ -1958,7 +3594,7 @@ function createEngine({
1958
3594
  const r_norm = Math.sqrt(x * x + y * y);
1959
3595
  const phi = Math.atan2(y, x);
1960
3596
  const theta = r_norm * (Math.PI / 2);
1961
- return new THREE5.Vector3(
3597
+ return new THREE6.Vector3(
1962
3598
  Math.sin(theta) * Math.cos(phi),
1963
3599
  Math.cos(theta),
1964
3600
  Math.sin(theta) * Math.sin(phi)
@@ -1971,22 +3607,23 @@ function createEngine({
1971
3607
  }
1972
3608
  }
1973
3609
  if (polyPoints.length > 0) {
1974
- const polyGeo = new THREE5.BufferGeometry();
1975
- polyGeo.setAttribute("position", new THREE5.Float32BufferAttribute(polyPoints, 3));
3610
+ const polyGeo = new THREE6.BufferGeometry();
3611
+ polyGeo.setAttribute("position", new THREE6.Float32BufferAttribute(polyPoints, 3));
1976
3612
  const polyMat = createSmartMaterial({
1977
- uniforms: { color: { value: new THREE5.Color(3718648) } },
3613
+ uniforms: { color: { value: new THREE6.Color(3718648) } },
1978
3614
  // Cyan-ish
1979
3615
  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; }`,
1980
3616
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
1981
3617
  transparent: true,
1982
3618
  depthWrite: false,
1983
- blending: THREE5.AdditiveBlending
3619
+ blending: THREE6.AdditiveBlending
1984
3620
  });
1985
- const polyLines = new THREE5.LineSegments(polyGeo, polyMat);
3621
+ const polyLines = new THREE6.LineSegments(polyGeo, polyMat);
1986
3622
  polyLines.frustumCulled = false;
1987
3623
  root.add(polyLines);
1988
3624
  }
1989
3625
  }
3626
+ labelManager.setLabels(dynamicLabels);
1990
3627
  resize();
1991
3628
  }
1992
3629
  let lastData = void 0;
@@ -2007,6 +3644,10 @@ function createEngine({
2007
3644
  }
2008
3645
  function setConfig(cfg) {
2009
3646
  currentConfig = cfg;
3647
+ applyGroundTheme(cfg);
3648
+ const externalFocusId = cfg.focus?.nodeId;
3649
+ if (typeof externalFocusId === "string") focusedNodeId = externalFocusId;
3650
+ if (externalFocusId === null) focusedNodeId = null;
2010
3651
  if (cfg.projection) setProjection(cfg.projection);
2011
3652
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
2012
3653
  state.lon = cfg.camera.lon;
@@ -2047,27 +3688,48 @@ function createEngine({
2047
3688
  if (lastModel) buildFromModel(lastModel, cfg);
2048
3689
  }
2049
3690
  if (cfg.constellations) {
3691
+ const getLayoutPosition = (id) => {
3692
+ const n = nodeById.get(id);
3693
+ if (!n) return null;
3694
+ const x = n.meta?.x ?? 0;
3695
+ const y = n.meta?.y ?? 0;
3696
+ const z = n.meta?.z ?? 0;
3697
+ if (z === 0) {
3698
+ const radius = cfg.layout?.radius ?? 2e3;
3699
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3700
+ const phi = Math.atan2(y, x);
3701
+ const theta = r_norm * (Math.PI / 2);
3702
+ return new THREE6.Vector3(
3703
+ Math.sin(theta) * Math.cos(phi),
3704
+ Math.cos(theta),
3705
+ Math.sin(theta) * Math.sin(phi)
3706
+ ).multiplyScalar(radius);
3707
+ }
3708
+ return new THREE6.Vector3(x, y, z);
3709
+ };
2050
3710
  constellationLayer.load(cfg.constellations, (id) => {
2051
3711
  if (cfg.arrangement && cfg.arrangement[id]) {
2052
3712
  const arr = cfg.arrangement[id];
2053
- if (arr.position[2] === 0) {
2054
- const x = arr.position[0];
2055
- const y = arr.position[1];
2056
- const radius = cfg.layout?.radius ?? 2e3;
2057
- const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
2058
- const phi = Math.atan2(y, x);
2059
- const theta = r_norm * (Math.PI / 2);
2060
- return new THREE5.Vector3(
2061
- Math.sin(theta) * Math.cos(phi),
2062
- Math.cos(theta),
2063
- Math.sin(theta) * Math.sin(phi)
2064
- ).multiplyScalar(radius);
3713
+ const coords = arr.center ?? arr.position;
3714
+ if (coords) {
3715
+ if (coords[2] === 0) {
3716
+ const x = coords[0];
3717
+ const y = coords[1];
3718
+ const radius = cfg.layout?.radius ?? 2e3;
3719
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
3720
+ const phi = Math.atan2(y, x);
3721
+ const theta = r_norm * (Math.PI / 2);
3722
+ return new THREE6.Vector3(
3723
+ Math.sin(theta) * Math.cos(phi),
3724
+ Math.cos(theta),
3725
+ Math.sin(theta) * Math.sin(phi)
3726
+ ).multiplyScalar(radius);
3727
+ }
3728
+ return new THREE6.Vector3(coords[0], coords[1], coords[2]);
2065
3729
  }
2066
- return new THREE5.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2067
3730
  }
2068
- const n = nodeById.get(id);
2069
- return n ? getPosition(n) : null;
2070
- });
3731
+ return getLayoutPosition(id);
3732
+ }, getLayoutPosition);
2071
3733
  }
2072
3734
  }
2073
3735
  function setHandlers(next) {
@@ -2092,7 +3754,7 @@ function createEngine({
2092
3754
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
2093
3755
  }
2094
3756
  for (const item of constellationLayer.getItems()) {
2095
- arr[item.config.id] = { position: [item.mesh.position.x, item.mesh.position.y, item.mesh.position.z] };
3757
+ arr[item.config.id] = { center: [item.center.x, item.center.y, item.center.z] };
2096
3758
  }
2097
3759
  Object.assign(arr, state.tempArrangement);
2098
3760
  return arr;
@@ -2116,60 +3778,70 @@ function createEngine({
2116
3778
  const uAspect = camera.aspect;
2117
3779
  const w = rect.width;
2118
3780
  const h = rect.height;
2119
- let closestLabel = null;
2120
- const LABEL_THRESHOLD = isTouchDevice ? 48 : 40;
2121
- let minLabelDist = LABEL_THRESHOLD;
2122
- for (const item of dynamicLabels) {
2123
- if (!item.obj.visible) continue;
2124
- if (isNodeFiltered(item.node)) continue;
2125
- const pWorld = item.obj.position;
2126
- const pProj = smartProjectJS(pWorld);
2127
- if (currentProjection.isClipped(pProj.z)) continue;
2128
- const xNDC = pProj.x * uScale / uAspect;
2129
- const yNDC = pProj.y * uScale;
2130
- const sX = (xNDC * 0.5 + 0.5) * w;
2131
- const sY = (-yNDC * 0.5 + 0.5) * h;
2132
- const dx = mX - sX;
2133
- const dy = mY - sY;
2134
- const d = Math.sqrt(dx * dx + dy * dy);
2135
- if (d < minLabelDist) {
2136
- minLabelDist = d;
2137
- closestLabel = item;
3781
+ const isEditMode = currentConfig?.editable ?? false;
3782
+ function pickLabel(threshold) {
3783
+ let closest = null;
3784
+ let minDist = threshold;
3785
+ for (const item of dynamicLabels) {
3786
+ if (!item.obj.visible) continue;
3787
+ if (isNodeFiltered(item.node)) continue;
3788
+ const labelMat = item.obj.material;
3789
+ if ((labelMat?.uniforms?.uAlpha?.value ?? 0) < 0.1) continue;
3790
+ const pWorld = item.obj.position;
3791
+ const pProj = smartProjectJS(pWorld);
3792
+ if (currentProjection.isClipped(pProj.z)) continue;
3793
+ const xNDC = pProj.x * uScale / uAspect;
3794
+ const yNDC = pProj.y * uScale;
3795
+ const sX = (xNDC * 0.5 + 0.5) * w;
3796
+ const sY = (-yNDC * 0.5 + 0.5) * h;
3797
+ const d = Math.sqrt((mX - sX) ** 2 + (mY - sY) ** 2);
3798
+ if (d < minDist) {
3799
+ minDist = d;
3800
+ closest = item;
3801
+ }
3802
+ }
3803
+ return closest;
3804
+ }
3805
+ if (isEditMode) {
3806
+ if (starPoints) {
3807
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
3808
+ raycaster.ray.origin.set(0, 0, 0);
3809
+ raycaster.ray.direction.copy(worldDir);
3810
+ raycaster.params.Points.threshold = 65 * (state.fov / 60);
3811
+ const hits = raycaster.intersectObject(starPoints, false);
3812
+ const pointHit = hits[0];
3813
+ if (pointHit && pointHit.index !== void 0) {
3814
+ const id = starIndexToId[pointHit.index];
3815
+ if (id) {
3816
+ const node = nodeById.get(id);
3817
+ if (node && !isNodeFiltered(node)) {
3818
+ const attr = starPoints.geometry.attributes.position;
3819
+ const starPos = new THREE6.Vector3(attr.getX(pointHit.index), attr.getY(pointHit.index), attr.getZ(pointHit.index));
3820
+ return { type: "star", node, index: pointHit.index, point: starPos, object: void 0 };
3821
+ }
3822
+ }
3823
+ }
3824
+ }
3825
+ const editLabel = pickLabel(isTouchDevice ? 48 : 32);
3826
+ if (editLabel) {
3827
+ return { type: "label", node: editLabel.node, object: editLabel.obj, point: editLabel.obj.position.clone(), index: void 0 };
2138
3828
  }
3829
+ return void 0;
2139
3830
  }
3831
+ const closestLabel = pickLabel(isTouchDevice ? 48 : 40);
2140
3832
  if (closestLabel) {
2141
3833
  return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
2142
3834
  }
2143
3835
  let closestConst = null;
2144
3836
  let minConstDist = Infinity;
3837
+ const artWorldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
3838
+ raycaster.ray.origin.set(0, 0, 0);
3839
+ raycaster.ray.direction.copy(artWorldDir);
2145
3840
  for (const item of constellationLayer.getItems()) {
2146
3841
  if (!item.mesh.visible) continue;
2147
- const pWorld = item.mesh.position;
2148
- const pProj = smartProjectJS(pWorld);
2149
- if (currentProjection.isClipped(pProj.z)) continue;
2150
- const uniforms = item.material.uniforms;
2151
- if (!uniforms || !uniforms.uSize) continue;
2152
- const uSize = uniforms.uSize.value;
2153
- const uImgAspect = uniforms.uImgAspect.value;
2154
- const uImgRotation = uniforms.uImgRotation.value;
2155
- const dist = pWorld.length();
2156
- if (dist < 1e-3) continue;
2157
- const scale = uSize / dist * uScale;
2158
- const halfH_px = scale / 2 * (h / 2);
2159
- const halfW_px = halfH_px * uImgAspect;
2160
- const xNDC = pProj.x * uScale / uAspect;
2161
- const yNDC = pProj.y * uScale;
2162
- const sX = (xNDC * 0.5 + 0.5) * w;
2163
- const sY = (-yNDC * 0.5 + 0.5) * h;
2164
- const dx = mX - sX;
2165
- const dy = mY - sY;
2166
- const dy_cart = -dy;
2167
- const cr = Math.cos(-uImgRotation);
2168
- const sr = Math.sin(-uImgRotation);
2169
- const localX = dx * cr - dy_cart * sr;
2170
- const localY = dx * sr + dy_cart * cr;
2171
- if (Math.abs(localX) < halfW_px * 1.2 && Math.abs(localY) < halfH_px * 1.2) {
2172
- const d = Math.sqrt(dx * dx + dy * dy);
3842
+ const hits = raycaster.intersectObject(item.mesh, false);
3843
+ if (hits.length > 0) {
3844
+ const d = hits[0].distance;
2173
3845
  if (!closestConst || d < minConstDist) {
2174
3846
  minConstDist = d;
2175
3847
  closestConst = item;
@@ -2182,7 +3854,7 @@ function createEngine({
2182
3854
  label: closestConst.config.title,
2183
3855
  level: -1
2184
3856
  };
2185
- return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.mesh.position.clone(), index: void 0 };
3857
+ return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.center.clone(), index: void 0 };
2186
3858
  }
2187
3859
  if (starPoints) {
2188
3860
  const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
@@ -2205,20 +3877,65 @@ function createEngine({
2205
3877
  isMouseInWindow = false;
2206
3878
  edgeHoverStart = 0;
2207
3879
  }
3880
+ function screenSpacePickStar(mx, my, maxPx = 50) {
3881
+ if (!starPoints) return null;
3882
+ const attr = starPoints.geometry.attributes.position;
3883
+ const rect = renderer.domElement.getBoundingClientRect();
3884
+ const w = rect.width;
3885
+ const h = rect.height;
3886
+ const uScale = globalUniforms.uScale.value;
3887
+ const uAspect = globalUniforms.uAspect.value;
3888
+ let bestIdx = -1;
3889
+ let bestDist2 = maxPx * maxPx;
3890
+ const worldPos = new THREE6.Vector3();
3891
+ for (let i = 0; i < attr.count; i++) {
3892
+ worldPos.set(attr.getX(i), attr.getY(i), attr.getZ(i));
3893
+ const proj = smartProjectJS(worldPos);
3894
+ if (currentProjection.isClipped(proj.z)) continue;
3895
+ const sx = (proj.x * uScale / uAspect * 0.5 + 0.5) * w;
3896
+ const sy = (-(proj.y * uScale) * 0.5 + 0.5) * h;
3897
+ const dx = mx - sx;
3898
+ const dy = my - sy;
3899
+ const d2 = dx * dx + dy * dy;
3900
+ if (d2 < bestDist2) {
3901
+ bestDist2 = d2;
3902
+ bestIdx = i;
3903
+ }
3904
+ }
3905
+ if (bestIdx < 0) return null;
3906
+ return {
3907
+ index: bestIdx,
3908
+ worldPos: new THREE6.Vector3(attr.getX(bestIdx), attr.getY(bestIdx), attr.getZ(bestIdx))
3909
+ };
3910
+ }
2208
3911
  function onMouseDown(e) {
2209
3912
  state.lastMouseX = e.clientX;
2210
3913
  state.lastMouseY = e.clientY;
2211
3914
  if (currentConfig?.editable) {
3915
+ const rect = renderer.domElement.getBoundingClientRect();
3916
+ const mX = e.clientX - rect.left;
3917
+ const mY = e.clientY - rect.top;
3918
+ const starHit = screenSpacePickStar(mX, mY);
3919
+ if (starHit) {
3920
+ state.dragMode = "node";
3921
+ state.draggedStarIndex = starHit.index;
3922
+ state.draggedNodeId = starIndexToId[starHit.index] ?? null;
3923
+ state.draggedDist = starHit.worldPos.length();
3924
+ state.draggedGroup = null;
3925
+ state.tempArrangement = {};
3926
+ state.velocityX = 0;
3927
+ state.velocityY = 0;
3928
+ return;
3929
+ }
2212
3930
  const hit = pick(e);
2213
- if (hit) {
3931
+ if (hit && (hit.type === "label" || hit.type === "constellation")) {
2214
3932
  state.dragMode = "node";
2215
3933
  state.draggedNodeId = hit.node.id;
2216
3934
  state.draggedDist = hit.point.length();
2217
- document.body.style.cursor = "crosshair";
2218
- if (hit.type === "star") {
2219
- state.draggedStarIndex = hit.index ?? -1;
2220
- state.draggedGroup = null;
2221
- } else if (hit.type === "label") {
3935
+ state.draggedStarIndex = -1;
3936
+ state.velocityX = 0;
3937
+ state.velocityY = 0;
3938
+ if (hit.type === "label") {
2222
3939
  const bookId = hit.node.id;
2223
3940
  const children = [];
2224
3941
  if (starPoints && starPoints.geometry.attributes.position) {
@@ -2228,17 +3945,26 @@ function createEngine({
2228
3945
  if (starId) {
2229
3946
  const starNode = nodeById.get(starId);
2230
3947
  if (starNode && starNode.parent === bookId) {
2231
- children.push({ index: i, initialPos: new THREE5.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
3948
+ children.push({ index: i, initialPos: new THREE6.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
2232
3949
  }
2233
3950
  }
2234
3951
  }
2235
3952
  }
2236
- state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
2237
- state.draggedStarIndex = -1;
2238
- } else if (hit.type === "constellation") {
2239
- state.draggedGroup = null;
2240
- state.draggedStarIndex = -1;
3953
+ const constellations = [];
3954
+ for (const cItem of constellationLayer.getItems()) {
3955
+ const anchored = cItem.config.anchors.some((anchorId) => {
3956
+ const n = nodeById.get(anchorId);
3957
+ return n?.parent === bookId;
3958
+ });
3959
+ if (anchored) {
3960
+ constellations.push({ id: cItem.config.id, initialCenter: cItem.center.clone() });
3961
+ }
3962
+ }
3963
+ state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children, constellations };
3964
+ } else {
3965
+ state.draggedGroup = { labelInitialPos: hit.point.clone(), children: [], constellations: [] };
2241
3966
  }
3967
+ return;
2242
3968
  }
2243
3969
  return;
2244
3970
  }
@@ -2265,6 +3991,8 @@ function createEngine({
2265
3991
  const attr = starPoints.geometry.attributes.position;
2266
3992
  attr.setXYZ(idx, newPos.x, newPos.y, newPos.z);
2267
3993
  attr.needsUpdate = true;
3994
+ const starId = starIndexToId[idx];
3995
+ if (starId) state.tempArrangement[starId] = { position: [newPos.x, newPos.y, newPos.z] };
2268
3996
  } else if (state.draggedGroup && state.draggedNodeId) {
2269
3997
  const group = state.draggedGroup;
2270
3998
  const item = dynamicLabels.find((l) => l.node.id === state.draggedNodeId);
@@ -2274,16 +4002,19 @@ function createEngine({
2274
4002
  } else if (state.draggedNodeId) {
2275
4003
  const cItem = constellationLayer.getItems().find((c) => c.config.id === state.draggedNodeId);
2276
4004
  if (cItem) {
2277
- cItem.mesh.position.copy(newPos);
2278
- state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
4005
+ const vS = group.labelInitialPos.clone().normalize();
4006
+ const vE = newPos.clone().normalize();
4007
+ cItem.mesh.quaternion.setFromUnitVectors(vS, vE);
4008
+ cItem.center.copy(newPos);
4009
+ state.tempArrangement[state.draggedNodeId] = { center: [newPos.x, newPos.y, newPos.z] };
2279
4010
  }
2280
4011
  }
2281
4012
  const vStart = group.labelInitialPos.clone().normalize();
2282
4013
  const vEnd = newPos.clone().normalize();
2283
- const q = new THREE5.Quaternion().setFromUnitVectors(vStart, vEnd);
4014
+ const q = new THREE6.Quaternion().setFromUnitVectors(vStart, vEnd);
2284
4015
  if (starPoints && group.children.length > 0) {
2285
4016
  const attr = starPoints.geometry.attributes.position;
2286
- const tempVec = new THREE5.Vector3();
4017
+ const tempVec = new THREE6.Vector3();
2287
4018
  for (const child of group.children) {
2288
4019
  tempVec.copy(child.initialPos).applyQuaternion(q);
2289
4020
  attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
@@ -2294,6 +4025,20 @@ function createEngine({
2294
4025
  }
2295
4026
  attr.needsUpdate = true;
2296
4027
  }
4028
+ if (group.constellations.length > 0) {
4029
+ for (const { id, initialCenter } of group.constellations) {
4030
+ const cItem = constellationLayer.getItems().find((c) => c.config.id === id);
4031
+ if (cItem) {
4032
+ const newCenter = initialCenter.clone().applyQuaternion(q);
4033
+ cItem.center.copy(newCenter);
4034
+ cItem.mesh.quaternion.setFromUnitVectors(
4035
+ initialCenter.clone().normalize(),
4036
+ newCenter.clone().normalize()
4037
+ );
4038
+ state.tempArrangement[id] = { center: [newCenter.x, newCenter.y, newCenter.z] };
4039
+ }
4040
+ }
4041
+ }
2297
4042
  }
2298
4043
  } else if (state.dragMode === "camera") {
2299
4044
  const deltaX = e.clientX - state.lastMouseX;
@@ -2301,13 +4046,15 @@ function createEngine({
2301
4046
  state.lastMouseX = e.clientX;
2302
4047
  state.lastMouseY = e.clientY;
2303
4048
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2304
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2305
- const latFactor = 1 - rotLock * rotLock;
2306
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2307
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4049
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4050
+ const massFactor = getMovementMassFactor(state.fov);
4051
+ const moveX = compressInputDelta(deltaX) * massFactor;
4052
+ const moveY = compressInputDelta(deltaY) * massFactor;
4053
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4054
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2308
4055
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2309
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2310
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4056
+ state.velocityX = moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4057
+ state.velocityY = moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2311
4058
  state.lon = state.targetLon;
2312
4059
  state.lat = state.targetLat;
2313
4060
  } else {
@@ -2319,7 +4066,7 @@ function createEngine({
2319
4066
  if (res) {
2320
4067
  hoverLabelMat.uniforms.uMap.value = res.tex;
2321
4068
  const baseScale = 0.03;
2322
- const size = new THREE5.Vector2(baseScale * res.aspect, baseScale);
4069
+ const size = new THREE6.Vector2(baseScale * res.aspect, baseScale);
2323
4070
  hoverLabelMat.uniforms.uSize.value = size;
2324
4071
  hoverLabelMesh.scale.set(size.x, size.y, 1);
2325
4072
  }
@@ -2337,7 +4084,7 @@ function createEngine({
2337
4084
  handlers.onHover?.(hit?.node);
2338
4085
  constellationLayer.setHovered(hit?.node.id ?? null);
2339
4086
  }
2340
- document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
4087
+ document.body.style.cursor = hit ? currentConfig?.editable && hit.type === "star" ? "grab" : "pointer" : "default";
2341
4088
  }
2342
4089
  }
2343
4090
  function onMouseUp(e) {
@@ -2361,10 +4108,12 @@ function createEngine({
2361
4108
  if (hit) {
2362
4109
  handlers.onSelect?.(hit.node);
2363
4110
  constellationLayer.setFocused(hit.node.id);
4111
+ focusedNodeId = hit.node.id;
2364
4112
  if (hit.node.level === 2) setFocusedBook(hit.node.id);
2365
4113
  else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2366
4114
  } else {
2367
4115
  setFocusedBook(null);
4116
+ focusedNodeId = null;
2368
4117
  }
2369
4118
  }
2370
4119
  } else {
@@ -2372,10 +4121,12 @@ function createEngine({
2372
4121
  if (hit) {
2373
4122
  handlers.onSelect?.(hit.node);
2374
4123
  constellationLayer.setFocused(hit.node.id);
4124
+ focusedNodeId = hit.node.id;
2375
4125
  if (hit.node.level === 2) setFocusedBook(hit.node.id);
2376
4126
  else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2377
4127
  } else {
2378
4128
  setFocusedBook(null);
4129
+ focusedNodeId = null;
2379
4130
  }
2380
4131
  }
2381
4132
  }
@@ -2385,44 +4136,55 @@ function createEngine({
2385
4136
  const aspect = container.clientWidth / container.clientHeight;
2386
4137
  renderer.domElement.getBoundingClientRect();
2387
4138
  const vBefore = getMouseViewVector(state.fov, aspect);
2388
- const zoomSpeed = 1e-3 * state.fov;
4139
+ const zoomResistance = THREE6.MathUtils.lerp(
4140
+ 1,
4141
+ ENGINE_CONFIG.zoomResistanceWideFov,
4142
+ THREE6.MathUtils.smoothstep(state.fov, 24, 100)
4143
+ );
4144
+ const zoomSpeed = 1e-3 * state.fov * zoomResistance;
2389
4145
  state.fov += e.deltaY * zoomSpeed;
2390
4146
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2391
4147
  handlers.onFovChange?.(state.fov);
2392
4148
  updateUniforms();
2393
4149
  const vAfter = getMouseViewVector(state.fov, aspect);
2394
- const quaternion = new THREE5.Quaternion().setFromUnitVectors(vAfter, vBefore);
2395
- const dampStartFov = 40;
2396
- const dampEndFov = 120;
4150
+ const quaternion = new THREE6.Quaternion().setFromUnitVectors(vAfter, vBefore);
4151
+ const dampStartFov = 32;
4152
+ const dampEndFov = 110;
2397
4153
  let spinAmount = 1;
2398
4154
  if (state.fov > dampStartFov) {
2399
4155
  const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2400
- spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
4156
+ spinAmount = 1 - Math.pow(t, 1.35) * 0.92;
2401
4157
  }
4158
+ const blendForSpin = getBlendForZenithControl();
4159
+ const blendSpinDamp = THREE6.MathUtils.smoothstep(blendForSpin, 0.58, 0.9);
4160
+ spinAmount *= 1 - 0.88 * blendSpinDamp;
4161
+ if (zenithProjectionLockActive) spinAmount = Math.min(spinAmount, 0.02);
4162
+ spinAmount = Math.max(0.02, Math.min(1, spinAmount));
2402
4163
  if (spinAmount < 0.999) {
2403
- const identityQuat = new THREE5.Quaternion();
4164
+ const identityQuat = new THREE6.Quaternion();
2404
4165
  quaternion.slerp(identityQuat, 1 - spinAmount);
2405
4166
  }
2406
4167
  const y = Math.sin(state.lat);
2407
4168
  const r = Math.cos(state.lat);
2408
4169
  const x = r * Math.sin(state.lon);
2409
4170
  const z = -r * Math.cos(state.lon);
2410
- const currentLook = new THREE5.Vector3(x, y, z);
4171
+ const currentLook = new THREE6.Vector3(x, y, z);
2411
4172
  const camForward = currentLook.clone().normalize();
2412
4173
  const camUp = camera.up.clone();
2413
- const camRight = new THREE5.Vector3().crossVectors(camForward, camUp).normalize();
2414
- const camUpOrtho = new THREE5.Vector3().crossVectors(camRight, camForward).normalize();
2415
- const mat = new THREE5.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
2416
- const qOld = new THREE5.Quaternion().setFromRotationMatrix(mat);
4174
+ const camRight = new THREE6.Vector3().crossVectors(camForward, camUp).normalize();
4175
+ const camUpOrtho = new THREE6.Vector3().crossVectors(camRight, camForward).normalize();
4176
+ const mat = new THREE6.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
4177
+ const qOld = new THREE6.Quaternion().setFromRotationMatrix(mat);
2417
4178
  const qNew = qOld.clone().multiply(quaternion);
2418
- const newForward = new THREE5.Vector3(0, 0, -1).applyQuaternion(qNew);
4179
+ const newForward = new THREE6.Vector3(0, 0, -1).applyQuaternion(qNew);
2419
4180
  state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
2420
4181
  state.lon = Math.atan2(newForward.x, -newForward.z);
2421
- const newUp = new THREE5.Vector3(0, 1, 0).applyQuaternion(qNew);
4182
+ const newUp = new THREE6.Vector3(0, 1, 0).applyQuaternion(qNew);
2422
4183
  camera.up.copy(newUp);
2423
- if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
2424
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
2425
- let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
4184
+ const zenithBiasStartFov = getZenithBiasStartFov();
4185
+ if (!zenithProjectionLockActive && !getSceneDebug()?.disableZenithBias && !isInTransitionFreezeBand(state.fov) && e.deltaY > 0 && state.fov > zenithBiasStartFov) {
4186
+ const range = ENGINE_CONFIG.maxFov - zenithBiasStartFov;
4187
+ let t = (state.fov - zenithBiasStartFov) / range;
2426
4188
  t = Math.max(0, Math.min(1, t));
2427
4189
  const bias = ENGINE_CONFIG.zenithStrength * t;
2428
4190
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -2514,13 +4276,15 @@ function createEngine({
2514
4276
  }
2515
4277
  }
2516
4278
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2517
- const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2518
- const latFactor = 1 - rotLock * rotLock;
2519
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2520
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4279
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4280
+ const massFactor = getMovementMassFactor(state.fov);
4281
+ const moveX = compressInputDelta(deltaX) * massFactor;
4282
+ const moveY = compressInputDelta(deltaY) * massFactor;
4283
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4284
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2521
4285
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2522
- state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2523
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
4286
+ state.velocityX = moveX * ENGINE_CONFIG.dragSpeed * speedScale;
4287
+ state.velocityY = moveY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2524
4288
  state.lon = state.targetLon;
2525
4289
  state.lat = state.targetLat;
2526
4290
  } else if (touches.length === 2) {
@@ -2532,9 +4296,10 @@ function createEngine({
2532
4296
  state.fov = state.pinchStartFov / scale;
2533
4297
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2534
4298
  handlers.onFovChange?.(state.fov);
2535
- if (state.fov > prevFov && state.fov > ENGINE_CONFIG.zenithStartFov) {
2536
- const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
2537
- let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
4299
+ const zenithBiasStartFov = getZenithBiasStartFov();
4300
+ if (!zenithProjectionLockActive && !getSceneDebug()?.disableZenithBias && !isInTransitionFreezeBand(state.fov) && state.fov > prevFov && state.fov > zenithBiasStartFov) {
4301
+ const range = ENGINE_CONFIG.maxFov - zenithBiasStartFov;
4302
+ let t = (state.fov - zenithBiasStartFov) / range;
2538
4303
  t = Math.max(0, Math.min(1, t));
2539
4304
  const bias = ENGINE_CONFIG.zenithStrength * t;
2540
4305
  const zenithLat = Math.PI / 2 - 1e-3;
@@ -2547,8 +4312,12 @@ function createEngine({
2547
4312
  state.lastMouseX = center.x;
2548
4313
  state.lastMouseY = center.y;
2549
4314
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2550
- state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2551
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4315
+ const latFactor = getVerticalPanFactor(state.fov, state.lat);
4316
+ const massFactor = getMovementMassFactor(state.fov);
4317
+ const moveX = compressInputDelta(deltaX) * massFactor;
4318
+ const moveY = compressInputDelta(deltaY) * massFactor;
4319
+ state.targetLon += moveX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
4320
+ state.targetLat += moveY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5 * latFactor;
2552
4321
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2553
4322
  state.lon = state.targetLon;
2554
4323
  state.lat = state.targetLat;
@@ -2711,7 +4480,9 @@ function createEngine({
2711
4480
  if (inZoneX || inZoneY) {
2712
4481
  if (edgeHoverStart === 0) edgeHoverStart = performance.now();
2713
4482
  if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
2714
- const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
4483
+ const edgeMassFactor = getMovementMassFactor(state.fov, ENGINE_CONFIG.edgePanMassWideFov);
4484
+ const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov) * edgeMassFactor;
4485
+ const verticalPanFactor = getVerticalPanFactor(state.fov, state.lat);
2715
4486
  if (mouseNDC.x < -1 + t) {
2716
4487
  const s = (-1 + t - mouseNDC.x) / t;
2717
4488
  panX = -s * s * speedBase;
@@ -2721,10 +4492,10 @@ function createEngine({
2721
4492
  }
2722
4493
  if (mouseNDC.y < -1 + t) {
2723
4494
  const s = (-1 + t - mouseNDC.y) / t;
2724
- panY = -s * s * speedBase;
4495
+ panY = -s * s * speedBase * verticalPanFactor;
2725
4496
  } else if (mouseNDC.y > 1 - t) {
2726
4497
  const s = (mouseNDC.y - (1 - t)) / t;
2727
- panY = s * s * speedBase;
4498
+ panY = s * s * speedBase * verticalPanFactor;
2728
4499
  }
2729
4500
  }
2730
4501
  } else {
@@ -2756,26 +4527,56 @@ function createEngine({
2756
4527
  state.targetLat = state.lat;
2757
4528
  } else if (!state.isDragging && !flyToActive) {
2758
4529
  state.lon += state.velocityX;
4530
+ state.velocityY *= getVerticalPanFactor(state.fov, state.lat);
2759
4531
  state.lat += state.velocityY;
2760
- const damping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
4532
+ const baseDamping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
4533
+ const speed = Math.hypot(state.velocityX, state.velocityY);
4534
+ const damping = speed < ENGINE_CONFIG.lowSpeedVelocityThreshold ? Math.min(baseDamping, ENGINE_CONFIG.lowSpeedInertiaDamping) : baseDamping;
2761
4535
  state.velocityX *= damping;
2762
4536
  state.velocityY *= damping;
2763
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2764
- if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
4537
+ if (Math.abs(state.velocityX) < ENGINE_CONFIG.velocityStopThreshold) state.velocityX = 0;
4538
+ if (Math.abs(state.velocityY) < ENGINE_CONFIG.velocityStopThreshold) state.velocityY = 0;
2765
4539
  }
2766
4540
  state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
4541
+ if (!flyToActive) {
4542
+ const latDeg = THREE6.MathUtils.radToDeg(state.lat);
4543
+ if (latDeg < HORIZON_ZOOM_CONFIG.latStartDeg) {
4544
+ const t = THREE6.MathUtils.clamp(latDeg / HORIZON_ZOOM_CONFIG.latStartDeg, 0, 1);
4545
+ const maxFov = THREE6.MathUtils.lerp(
4546
+ HORIZON_ZOOM_CONFIG.safeFovAtHorizon,
4547
+ ENGINE_CONFIG.maxFov,
4548
+ t
4549
+ );
4550
+ if (state.fov > maxFov) {
4551
+ state.fov = THREE6.MathUtils.lerp(state.fov, maxFov, HORIZON_ZOOM_CONFIG.lerpRate);
4552
+ }
4553
+ }
4554
+ }
4555
+ applyZenithAutoCenter();
2767
4556
  const y = Math.sin(state.lat);
2768
4557
  const r = Math.cos(state.lat);
2769
4558
  const x = r * Math.sin(state.lon);
2770
4559
  const z = -r * Math.cos(state.lon);
2771
- const target = new THREE5.Vector3(x, y, z);
2772
- const idealUp = new THREE5.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
4560
+ const target = new THREE6.Vector3(x, y, z);
4561
+ const idealUp = new THREE6.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
2773
4562
  camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
2774
4563
  camera.up.normalize();
2775
4564
  camera.lookAt(target);
2776
4565
  camera.updateMatrixWorld();
2777
4566
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
4567
+ if (groundMaterial?.uniforms?.uZenithFlatten) {
4568
+ const targetFlatten = getSceneDebug()?.disableZenithFlatten ? 0 : THREE6.MathUtils.smoothstep(
4569
+ state.lat,
4570
+ THREE6.MathUtils.degToRad(68),
4571
+ THREE6.MathUtils.degToRad(88)
4572
+ );
4573
+ const prevFlatten = Number(groundMaterial.uniforms.uZenithFlatten.value ?? 0);
4574
+ const flatten = isInTransitionFreezeBand(state.fov) ? THREE6.MathUtils.clamp(targetFlatten, prevFlatten - 0.01, prevFlatten + 0.01) : targetFlatten;
4575
+ groundMaterial.uniforms.uZenithFlatten.value = flatten;
4576
+ }
2778
4577
  updateUniforms();
4578
+ if (getSceneDebug()?.horizonDiagnostics) runHorizonDiagnostics(now);
4579
+ updateChapterLabelAnchors();
2779
4580
  const nowSec = now / 1e3;
2780
4581
  const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2781
4582
  lastTickTime = nowSec;
@@ -2783,21 +4584,44 @@ function createEngine({
2783
4584
  linesFader.update(dt);
2784
4585
  artFader.target = currentConfig?.showConstellationArt ?? false;
2785
4586
  artFader.update(dt);
2786
- constellationLayer.update(state.fov, artFader.eased > 0.01);
2787
- if (artFader.eased < 1) {
2788
- constellationLayer.setGlobalOpacity?.(artFader.eased);
2789
- }
4587
+ constellationLayer.update(state.fov, artFader.eased > 0.01, camera, dt);
4588
+ const baseArtOpacity = THREE6.MathUtils.clamp(currentConfig?.constellationBaseOpacity ?? 1, 0, 300);
4589
+ constellationLayer.setGlobalOpacity?.(artFader.eased * baseArtOpacity);
2790
4590
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
4591
+ const revealZoom = currentConfig?.starZoomReveal ?? true ? THREE6.MathUtils.clamp(
4592
+ (ZOOM_REVEAL_CONFIG.wideFov - state.fov) / (ZOOM_REVEAL_CONFIG.wideFov - ZOOM_REVEAL_CONFIG.narrowFov),
4593
+ 0,
4594
+ 1
4595
+ ) : 1;
4596
+ if (backdropStarsMaterial?.uniforms) {
4597
+ const minGain = THREE6.MathUtils.clamp(currentConfig?.backdropWideFovGain ?? 0.42, 0, 1);
4598
+ const fovT = THREE6.MathUtils.smoothstep(state.fov, 24, 100);
4599
+ const gain = THREE6.MathUtils.lerp(1, minGain, fovT);
4600
+ backdropStarsMaterial.uniforms.uBackdropGain.value = gain;
4601
+ backdropStarsMaterial.uniforms.uBackdropEnergy.value = THREE6.MathUtils.clamp(currentConfig?.backdropEnergy ?? 2.2, 0.2, 5);
4602
+ backdropStarsMaterial.uniforms.uBackdropSizeExp.value = THREE6.MathUtils.clamp(currentConfig?.backdropSizeExponent ?? 0.9, 0.4, 1.4);
4603
+ backdropStarsMaterial.uniforms.uRevealZoom.value = revealZoom;
4604
+ }
4605
+ if (starPoints?.material) {
4606
+ const sm = starPoints.material;
4607
+ if (sm.uniforms.uRevealZoom) sm.uniforms.uRevealZoom.value = revealZoom;
4608
+ }
4609
+ if (skyBackgroundMesh) skyBackgroundMesh.visible = currentConfig?.background !== "transparent";
2791
4610
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2792
- const DIVISION_THRESHOLD = 60;
2793
- const showDivisions = state.fov > DIVISION_THRESHOLD;
4611
+ if (moonMesh) moonMesh.visible = currentConfig?.showMoon ?? true;
4612
+ if (moonGlowMesh) moonGlowMesh.visible = currentConfig?.showMoon ?? true;
4613
+ const showSun = currentConfig?.showSunrise ?? true;
4614
+ if (sunDiscMesh) sunDiscMesh.visible = showSun;
4615
+ if (sunHaloMesh) sunHaloMesh.visible = showSun;
4616
+ if (milkyWayMesh) milkyWayMesh.visible = currentConfig?.showMilkyWay ?? true;
2794
4617
  if (constellationLines) {
2795
4618
  constellationLines.visible = linesFader.eased > 0.01;
2796
4619
  if (constellationLines.visible && constellationLines.material) {
2797
4620
  const mat = constellationLines.material;
2798
4621
  if (mat.uniforms?.color) {
2799
4622
  mat.uniforms.color.value.setHex(11193599);
2800
- mat.opacity = linesFader.eased;
4623
+ if (mat.uniforms.uReveal) mat.uniforms.uReveal.value = linesFader.eased;
4624
+ mat.opacity = 1;
2801
4625
  }
2802
4626
  }
2803
4627
  }
@@ -2808,116 +4632,35 @@ function createEngine({
2808
4632
  const screenW = rect.width;
2809
4633
  const screenH = rect.height;
2810
4634
  const aspect = screenW / screenH;
2811
- const labelsToCheck = [];
2812
- const occupied = [];
2813
- function isOverlapping(x2, y2, w, h) {
2814
- for (const r2 of occupied) {
2815
- if (x2 < r2.x + r2.w && x2 + w > r2.x && y2 < r2.y + r2.h && y2 + h > r2.y) return true;
2816
- }
2817
- return false;
2818
- }
2819
- const showBookLabels = currentConfig?.showBookLabels === true;
2820
- const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2821
- const showChapterLabels = currentConfig?.showChapterLabels === true;
2822
- const showGroupLabels = currentConfig?.showGroupLabels === true;
2823
- const showChapters = state.fov < 45;
2824
- for (const item of dynamicLabels) {
2825
- const uniforms = item.obj.material.uniforms;
2826
- const level = item.node.level;
2827
- let isEnabled = false;
2828
- if (level === 2 && showBookLabels) isEnabled = true;
2829
- else if (level === 1 && showDivisionLabels) isEnabled = true;
2830
- else if (level === 3 && showChapterLabels) isEnabled = true;
2831
- else if (level === 2.5 && showGroupLabels) isEnabled = true;
2832
- if (!isEnabled) {
2833
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2834
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2835
- continue;
2836
- }
2837
- const pWorld = item.obj.position;
2838
- const pProj = smartProjectJS(pWorld);
2839
- if (pProj.z > 0.2) {
2840
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2841
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2842
- continue;
2843
- }
2844
- if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2845
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2846
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2847
- continue;
2848
- }
2849
- const ndcX = pProj.x * globalUniforms.uScale.value / aspect;
2850
- const ndcY = pProj.y * globalUniforms.uScale.value;
2851
- const sX = (ndcX * 0.5 + 0.5) * screenW;
2852
- const sY = (-ndcY * 0.5 + 0.5) * screenH;
2853
- const size = uniforms.uSize.value;
2854
- const pixelH = size.y * screenH * 0.8;
2855
- const pixelW = size.x * screenH * 0.8;
2856
- labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level, ndcX, ndcY });
2857
- }
2858
- const hoverId = handlers._lastHoverId;
4635
+ const hoverId = handlers._lastHoverId ?? null;
2859
4636
  const selectedId = state.draggedNodeId;
2860
- labelsToCheck.sort((a, b) => {
2861
- const getScore = (l) => {
2862
- if (l.item.node.id === selectedId) return 10;
2863
- if (l.item.node.id === hoverId) return 9;
2864
- const level = l.level;
2865
- if (level === 2) return 5;
2866
- if (level === 1) return showDivisions ? 6 : 1;
2867
- return 0;
2868
- };
2869
- return getScore(b) - getScore(a);
4637
+ labelManager.update({
4638
+ nowMs: now,
4639
+ dt,
4640
+ fov: state.fov,
4641
+ camera,
4642
+ projectionId: currentProjection.id,
4643
+ screenW,
4644
+ screenH,
4645
+ globalScale: globalUniforms.uScale.value,
4646
+ aspect,
4647
+ hoverId,
4648
+ selectedId,
4649
+ focusedId: focusedNodeId,
4650
+ shouldFilter: !!currentFilter && filterStrength > 0.01,
4651
+ isNodeFiltered: (node) => {
4652
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
4653
+ return !!nodeToCheck && isNodeFiltered(nodeToCheck);
4654
+ },
4655
+ toggles: {
4656
+ showBookLabels: currentConfig?.showBookLabels === true,
4657
+ showDivisionLabels: currentConfig?.showDivisionLabels === true,
4658
+ showChapterLabels: currentConfig?.showChapterLabels === true,
4659
+ showGroupLabels: currentConfig?.showGroupLabels === true
4660
+ },
4661
+ config: currentConfig?.labelBehavior,
4662
+ project: smartProjectJS
2870
4663
  });
2871
- for (const l of labelsToCheck) {
2872
- let target2 = 0;
2873
- const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2874
- if (l.level === 1) {
2875
- let rot = 0;
2876
- const isWideAngle = currentProjection.id !== "perspective";
2877
- if (isWideAngle) {
2878
- const dx = l.sX - screenW / 2;
2879
- const dy = l.sY - screenH / 2;
2880
- rot = Math.atan2(-dy, -dx) - Math.PI / 2;
2881
- }
2882
- l.uniforms.uAngle.value = THREE5.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2883
- }
2884
- if (l.level === 2) {
2885
- {
2886
- target2 = 1;
2887
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2888
- }
2889
- } else if (l.level === 1) {
2890
- if (showDivisions || isSpecial) {
2891
- const pad = -5;
2892
- if (!isOverlapping(l.sX - l.w / 2 - pad, l.sY - l.h / 2 - pad, l.w + pad * 2, l.h + pad * 2)) {
2893
- target2 = 1;
2894
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2895
- }
2896
- }
2897
- } else if (l.level === 2.5 || l.level === 3) {
2898
- if (showChapters || isSpecial) {
2899
- target2 = 1;
2900
- if (!isSpecial) {
2901
- const dist = Math.sqrt(l.ndcX * l.ndcX + l.ndcY * l.ndcY);
2902
- const focusFade = 1 - THREE5.MathUtils.smoothstep(0.4, 0.7, dist);
2903
- target2 *= focusFade;
2904
- }
2905
- }
2906
- }
2907
- if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2908
- const node = l.item.node;
2909
- if (node.level === 3) {
2910
- target2 = 0;
2911
- } else if (node.level === 2 || node.level === 2.5) {
2912
- const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2913
- if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2914
- target2 = 0;
2915
- }
2916
- }
2917
- }
2918
- l.uniforms.uAlpha.value = THREE5.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2919
- l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2920
- }
2921
4664
  renderer.render(scene, camera);
2922
4665
  }
2923
4666
  function stop() {
@@ -2942,6 +4685,42 @@ function createEngine({
2942
4685
  function dispose() {
2943
4686
  stop();
2944
4687
  constellationLayer.dispose();
4688
+ if (moonMesh) {
4689
+ scene.remove(moonMesh);
4690
+ moonMesh.geometry.dispose();
4691
+ moonMesh.material.dispose();
4692
+ moonMesh = null;
4693
+ }
4694
+ if (moonGlowMesh) {
4695
+ scene.remove(moonGlowMesh);
4696
+ moonGlowMesh.geometry.dispose();
4697
+ moonGlowMesh.material.dispose();
4698
+ moonGlowMesh = null;
4699
+ }
4700
+ if (sunDiscMesh) {
4701
+ scene.remove(sunDiscMesh);
4702
+ sunDiscMesh.geometry.dispose();
4703
+ sunDiscMesh.material.dispose();
4704
+ sunDiscMesh = null;
4705
+ }
4706
+ if (sunHaloMesh) {
4707
+ scene.remove(sunHaloMesh);
4708
+ sunHaloMesh.geometry.dispose();
4709
+ sunHaloMesh.material.dispose();
4710
+ sunHaloMesh = null;
4711
+ }
4712
+ if (milkyWayMesh) {
4713
+ scene.remove(milkyWayMesh);
4714
+ milkyWayMesh.geometry.dispose();
4715
+ milkyWayMesh.material.dispose();
4716
+ milkyWayMesh = null;
4717
+ }
4718
+ if (skyBackgroundMesh) {
4719
+ scene.remove(skyBackgroundMesh);
4720
+ skyBackgroundMesh.geometry.dispose();
4721
+ skyBackgroundMesh.material.dispose();
4722
+ skyBackgroundMesh = null;
4723
+ }
2945
4724
  renderer.dispose();
2946
4725
  renderer.domElement.remove();
2947
4726
  }
@@ -2961,6 +4740,7 @@ function createEngine({
2961
4740
  function flyTo(nodeId, targetFov) {
2962
4741
  const node = nodeById.get(nodeId);
2963
4742
  if (!node) return;
4743
+ focusedNodeId = nodeId;
2964
4744
  const pos = getPosition(node).normalize();
2965
4745
  flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2966
4746
  flyToTargetLon = Math.atan2(pos.x, -pos.z);
@@ -2983,7 +4763,7 @@ function createEngine({
2983
4763
  }
2984
4764
  return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
2985
4765
  }
2986
- var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
4766
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG, HORIZON_ZOOM_CONFIG, ZOOM_REVEAL_CONFIG;
2987
4767
  var init_createEngine = __esm({
2988
4768
  "src/engine/createEngine.ts"() {
2989
4769
  init_layout();
@@ -2991,14 +4771,35 @@ var init_createEngine = __esm({
2991
4771
  init_ConstellationArtworkLayer();
2992
4772
  init_projections();
2993
4773
  init_fader();
4774
+ init_LabelManager();
2994
4775
  ENGINE_CONFIG = {
2995
4776
  minFov: 1,
2996
4777
  maxFov: 135,
2997
- defaultFov: 50,
4778
+ defaultFov: 35,
2998
4779
  dragSpeed: 125e-5,
2999
4780
  inertiaDamping: 0.92,
4781
+ lowSpeedInertiaDamping: 0.78,
4782
+ lowSpeedVelocityThreshold: 25e-4,
4783
+ velocityStopThreshold: 4e-5,
4784
+ zoomResistanceWideFov: 0.82,
4785
+ movementMassWideFov: 0.74,
4786
+ edgePanMassWideFov: 0.68,
4787
+ inputCompression: 0.018,
3000
4788
  blendStart: 35,
3001
4789
  blendEnd: 83,
4790
+ freezeBandStartFov: 76,
4791
+ freezeBandEndFov: 84,
4792
+ zenithBiasStartFov: 85,
4793
+ zenithLockBlendEnter: 0.9,
4794
+ zenithLockBlendExit: 0.8,
4795
+ zenithAutoCenterBlendStart: 0.62,
4796
+ zenithAutoCenterBlendEnd: 0.9,
4797
+ zenithAutoCenterMinLerp: 0.012,
4798
+ zenithAutoCenterMaxLerp: 0.16,
4799
+ verticalPanDampStartFov: 72,
4800
+ verticalPanDampEndFov: 96,
4801
+ verticalPanDampLatStartDeg: 45,
4802
+ verticalPanDampLatEndDeg: 82,
3002
4803
  zenithStartFov: 75,
3003
4804
  zenithStrength: 0.15,
3004
4805
  horizonLockStrength: 0.05,
@@ -3021,10 +4822,34 @@ var init_createEngine = __esm({
3021
4822
  };
3022
4823
  ORDER_REVEAL_CONFIG = {
3023
4824
  globalDim: 0.85,
3024
- pulseAmplitude: 0.6,
4825
+ pulseAmplitude: 0.12,
3025
4826
  pulseDuration: 2,
3026
4827
  delayPerChapter: 0.1
3027
4828
  };
4829
+ HORIZON_ZOOM_CONFIG = {
4830
+ latStartDeg: 20,
4831
+ // coupling is fully off above this elevation
4832
+ safeFovAtHorizon: 60,
4833
+ // max FOV at the horizon (below freeze-band threshold)
4834
+ lerpRate: 0.03
4835
+ // gentle — should feel like a natural breathing-in
4836
+ };
4837
+ ZOOM_REVEAL_CONFIG = {
4838
+ wideFov: 120,
4839
+ // above this FOV, revealZoom = 0 (nothing new revealed)
4840
+ narrowFov: 8,
4841
+ // below this FOV, revealZoom = 1 (everything visible)
4842
+ zoomCurveExp: 1.8,
4843
+ // non-linear curve exponent (try 1.5 – 2.5)
4844
+ chapterRevealMax: 0.5,
4845
+ // faintest chapter star threshold — visible by ~fov 35
4846
+ chapterFeather: 0.1,
4847
+ // smoothstep width for chapter star fade-in
4848
+ backdropRevealStart: 0.4,
4849
+ // backdrop starts appearing at this mappedZoom
4850
+ backdropRevealEnd: 0.65
4851
+ // backdrop fully visible at this mappedZoom
4852
+ };
3028
4853
  }
3029
4854
  });
3030
4855
  var StarMap = forwardRef(
@@ -32211,7 +34036,7 @@ var RNG = class {
32211
34036
  const r = Math.sqrt(1 - y * y);
32212
34037
  const x = r * Math.cos(theta);
32213
34038
  const z = r * Math.sin(theta);
32214
- return new THREE5.Vector3(x, y, z);
34039
+ return new THREE6.Vector3(x, y, z);
32215
34040
  }
32216
34041
  };
32217
34042
  function simpleNoise3D(v, scale) {
@@ -32249,11 +34074,11 @@ function generateArrangement(bible, options = {}) {
32249
34074
  });
32250
34075
  });
32251
34076
  const bookCount = books.length;
32252
- const mwRad = THREE5.MathUtils.degToRad(opts.milkyWayAngle);
32253
- const mwNormal = new THREE5.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
34077
+ const mwRad = THREE6.MathUtils.degToRad(opts.milkyWayAngle);
34078
+ const mwNormal = new THREE6.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
32254
34079
  const anchors = [];
32255
34080
  for (let i = 0; i < bookCount; i++) {
32256
- let bestP = new THREE5.Vector3();
34081
+ let bestP = new THREE6.Vector3();
32257
34082
  let valid = false;
32258
34083
  let attempt = 0;
32259
34084
  while (!valid && attempt < 100) {
@@ -32279,7 +34104,7 @@ function generateArrangement(bible, options = {}) {
32279
34104
  arrangement[`B:${book.key}`] = { position: [anchorPos.x, anchorPos.y, anchorPos.z] };
32280
34105
  for (let c = 0; c < book.chapters; c++) {
32281
34106
  const localSpread = opts.clusterSpread * (0.8 + rng.next() * 0.4);
32282
- const offset = new THREE5.Vector3(
34107
+ const offset = new THREE6.Vector3(
32283
34108
  (rng.next() - 0.5) * 2,
32284
34109
  (rng.next() - 0.5) * 2,
32285
34110
  (rng.next() - 0.5) * 2
@@ -32300,7 +34125,7 @@ function generateArrangement(bible, options = {}) {
32300
34125
  const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
32301
34126
  const divId = `D:${book.testament}:${book.division}`;
32302
34127
  if (!divisions.has(divId)) {
32303
- divisions.set(divId, { sum: new THREE5.Vector3(), count: 0 });
34128
+ divisions.set(divId, { sum: new THREE6.Vector3(), count: 0 });
32304
34129
  }
32305
34130
  const entry = divisions.get(divId);
32306
34131
  entry.sum.add(anchorPos);