@project-skymap/library 0.5.0 → 0.6.0

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
@@ -319,45 +319,60 @@ var init_shaders = __esm({
319
319
  uniform float uScale;
320
320
  uniform float uAspect;
321
321
  uniform float uBlend;
322
+ uniform int uProjectionType;
322
323
 
323
324
  vec4 smartProject(vec4 viewPos) {
324
325
  vec3 dir = normalize(viewPos.xyz);
325
326
  float dist = length(viewPos.xyz);
326
- float zLinear = max(0.01, -dir.z);
327
- float kStereo = 2.0 / (1.0 - dir.z);
328
- float kLinear = 1.0 / zLinear;
329
- float k = mix(kLinear, kStereo, uBlend);
330
- vec2 projected = vec2(k * dir.x, k * dir.y);
331
- projected *= uScale;
332
- projected.x /= uAspect;
333
- float zMetric = -1.0 + (dist / 15000.0);
334
-
327
+ float k;
328
+
335
329
  // Radial Clipping: Push clipped points off-screen in their natural direction
336
330
  // to prevent lines "darting" across the center.
337
331
  vec2 escapeDir = (length(dir.xy) > 0.0001) ? normalize(dir.xy) : vec2(1.0, 1.0);
338
332
  vec2 escapePos = escapeDir * 10000.0;
339
333
 
340
- // Clip backward facing points in fisheye mode
341
- if (uBlend > 0.5 && dir.z > 0.4) return vec4(escapePos, 10.0, 1.0);
342
- // Clip very close points in linear mode
343
- if (uBlend < 0.1 && dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
344
-
334
+ if (uProjectionType == 0) {
335
+ // Perspective
336
+ if (dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
337
+ k = 1.0 / max(0.01, -dir.z);
338
+ } else if (uProjectionType == 1) {
339
+ // Stereographic \u2014 tighter clip to prevent stretch near singularity
340
+ if (dir.z > 0.1) return vec4(escapePos, 10.0, 1.0);
341
+ k = 2.0 / (1.0 - dir.z);
342
+ } else {
343
+ // Blended (auto-blend behavior)
344
+ float zLinear = max(0.01, -dir.z);
345
+ float kStereo = 2.0 / (1.0 - dir.z);
346
+ float kLinear = 1.0 / zLinear;
347
+ k = mix(kLinear, kStereo, uBlend);
348
+
349
+ // Tighter clip threshold that scales with blend factor
350
+ float clipZ = mix(-0.1, 0.1, uBlend);
351
+ if (dir.z > clipZ) return vec4(escapePos, 10.0, 1.0);
352
+ }
353
+
354
+ vec2 projected = vec2(k * dir.x, k * dir.y);
355
+ projected *= uScale;
356
+ projected.x /= uAspect;
357
+ float zMetric = -1.0 + (dist / 15000.0);
358
+
345
359
  return vec4(projected, zMetric, 1.0);
346
360
  }
347
361
  `;
348
362
  MASK_CHUNK = `
349
363
  uniform float uAspect;
350
364
  uniform float uBlend;
365
+ uniform int uProjectionType;
351
366
  varying vec2 vScreenPos;
352
367
  float getMaskAlpha() {
353
- if (uBlend < 0.1) return 1.0;
368
+ // No artificial circular mask \u2014 the horizon, atmosphere, and ground
369
+ // define the dome boundary naturally (as Stellarium does).
370
+ // Only apply a minimal edge softening to catch stray back-face artifacts.
354
371
  vec2 p = vScreenPos;
355
372
  p.x *= uAspect;
356
373
  float dist = length(p);
357
- float t = smoothstep(0.75, 1.0, uBlend);
358
- float currentRadius = mix(2.5, 1.0, t);
359
- float edgeSoftness = mix(0.5, 0.02, t);
360
- return 1.0 - smoothstep(currentRadius - edgeSoftness, currentRadius, dist);
374
+ // Gentle falloff only at extreme screen edges (beyond NDC ~1.8)
375
+ return 1.0 - smoothstep(1.8, 2.0, dist);
361
376
  }
362
377
  `;
363
378
  }
@@ -390,13 +405,15 @@ var init_materials = __esm({
390
405
  uScale: { value: 1 },
391
406
  uAspect: { value: 1 },
392
407
  uBlend: { value: 0 },
408
+ uProjectionType: { value: 2 },
409
+ // 0=perspective, 1=stereographic, 2=blended
393
410
  uTime: { value: 0 },
394
411
  // Atmosphere Settings
395
412
  uAtmGlow: { value: 1 },
396
413
  uAtmDark: { value: 0.6 },
397
414
  uAtmExtinction: { value: 4 },
398
415
  uAtmTwinkle: { value: 0.8 },
399
- uColorHorizon: { value: new THREE5.Color(2768476) },
416
+ uColorHorizon: { value: new THREE5.Color(3825292) },
400
417
  uColorZenith: { value: new THREE5.Color(132104) }
401
418
  };
402
419
  }
@@ -595,6 +612,10 @@ var init_ConstellationArtworkLayer = __esm({
595
612
  this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
596
613
  });
597
614
  }
615
+ _globalOpacity = 1;
616
+ setGlobalOpacity(v) {
617
+ this._globalOpacity = v;
618
+ }
598
619
  update(fov, showArt) {
599
620
  this.root.visible = showArt;
600
621
  if (!showArt) return;
@@ -609,7 +630,7 @@ var init_ConstellationArtworkLayer = __esm({
609
630
  const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
610
631
  opacity = THREE5.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
611
632
  }
612
- opacity = Math.min(Math.max(opacity, 0), 1);
633
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
613
634
  item.material.uniforms.uOpacity.value = opacity;
614
635
  }
615
636
  }
@@ -635,6 +656,164 @@ var init_ConstellationArtworkLayer = __esm({
635
656
  }
636
657
  });
637
658
 
659
+ // src/engine/projections.ts
660
+ var PerspectiveProjection, StereographicProjection, BlendedProjection, PROJECTIONS;
661
+ var init_projections = __esm({
662
+ "src/engine/projections.ts"() {
663
+ PerspectiveProjection = class {
664
+ id = "perspective";
665
+ label = "Perspective";
666
+ maxFov = 160;
667
+ glslProjectionType = 0;
668
+ forward(dir) {
669
+ if (dir.z > -0.1) return null;
670
+ const k = 1 / Math.max(0.01, -dir.z);
671
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
672
+ }
673
+ inverse(uvX, uvY, fovRad) {
674
+ const halfHeight = Math.tan(fovRad / 2);
675
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
676
+ const theta = Math.atan(r * halfHeight);
677
+ const phi = Math.atan2(uvY, uvX);
678
+ const sinT = Math.sin(theta);
679
+ return {
680
+ x: sinT * Math.cos(phi),
681
+ y: sinT * Math.sin(phi),
682
+ z: -Math.cos(theta)
683
+ };
684
+ }
685
+ getScale(fovRad) {
686
+ return 1 / Math.tan(fovRad / 2);
687
+ }
688
+ isClipped(dirZ) {
689
+ return dirZ > -0.1;
690
+ }
691
+ };
692
+ StereographicProjection = class {
693
+ id = "stereographic";
694
+ label = "Stereographic";
695
+ maxFov = 360;
696
+ glslProjectionType = 1;
697
+ forward(dir) {
698
+ if (dir.z > 0.4) return null;
699
+ const k = 2 / (1 - dir.z);
700
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
701
+ }
702
+ inverse(uvX, uvY, fovRad) {
703
+ const halfHeight = 2 * Math.tan(fovRad / 4);
704
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
705
+ const theta = 2 * Math.atan(r * halfHeight / 2);
706
+ const phi = Math.atan2(uvY, uvX);
707
+ const sinT = Math.sin(theta);
708
+ return {
709
+ x: sinT * Math.cos(phi),
710
+ y: sinT * Math.sin(phi),
711
+ z: -Math.cos(theta)
712
+ };
713
+ }
714
+ getScale(fovRad) {
715
+ return 1 / (2 * Math.tan(fovRad / 4));
716
+ }
717
+ isClipped(dirZ) {
718
+ return dirZ > 0.4;
719
+ }
720
+ };
721
+ BlendedProjection = class {
722
+ id = "blended";
723
+ label = "Blended (Auto)";
724
+ maxFov = 165;
725
+ glslProjectionType = 2;
726
+ /** FOV thresholds for blend transition (degrees) */
727
+ blendStart = 40;
728
+ blendEnd = 100;
729
+ /** Current blend factor, updated via setFov() */
730
+ blend = 0;
731
+ /** Call this each frame / when FOV changes so forward/inverse stay in sync */
732
+ setFov(fovDeg) {
733
+ if (fovDeg <= this.blendStart) {
734
+ this.blend = 0;
735
+ return;
736
+ }
737
+ if (fovDeg >= this.blendEnd) {
738
+ this.blend = 1;
739
+ return;
740
+ }
741
+ const t = (fovDeg - this.blendStart) / (this.blendEnd - this.blendStart);
742
+ this.blend = t * t * (3 - 2 * t);
743
+ }
744
+ getBlend() {
745
+ return this.blend;
746
+ }
747
+ forward(dir) {
748
+ if (this.blend > 0.5 && dir.z > 0.4) return null;
749
+ if (this.blend < 0.1 && dir.z > -0.1) return null;
750
+ const kLinear = 1 / Math.max(0.01, -dir.z);
751
+ const kStereo = 2 / (1 - dir.z);
752
+ const k = kLinear * (1 - this.blend) + kStereo * this.blend;
753
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
754
+ }
755
+ inverse(uvX, uvY, fovRad) {
756
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
757
+ const halfHeightLin = Math.tan(fovRad / 2);
758
+ const thetaLin = Math.atan(r * halfHeightLin);
759
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
760
+ const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
761
+ const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
762
+ const phi = Math.atan2(uvY, uvX);
763
+ const sinT = Math.sin(theta);
764
+ return {
765
+ x: sinT * Math.cos(phi),
766
+ y: sinT * Math.sin(phi),
767
+ z: -Math.cos(theta)
768
+ };
769
+ }
770
+ getScale(fovRad) {
771
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
772
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
773
+ return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
774
+ }
775
+ isClipped(dirZ) {
776
+ if (this.blend > 0.5) return dirZ > 0.4;
777
+ if (this.blend < 0.1) return dirZ > -0.1;
778
+ return false;
779
+ }
780
+ };
781
+ PROJECTIONS = {
782
+ perspective: () => new PerspectiveProjection(),
783
+ stereographic: () => new StereographicProjection(),
784
+ blended: () => new BlendedProjection()
785
+ };
786
+ }
787
+ });
788
+
789
+ // src/engine/fader.ts
790
+ var Fader;
791
+ var init_fader = __esm({
792
+ "src/engine/fader.ts"() {
793
+ Fader = class {
794
+ target = false;
795
+ value = 0;
796
+ duration;
797
+ constructor(duration = 0.3) {
798
+ this.duration = duration;
799
+ }
800
+ update(dt) {
801
+ const goal = this.target ? 1 : 0;
802
+ if (this.value === goal) return;
803
+ const speed = 1 / this.duration;
804
+ const step = speed * dt;
805
+ const diff = goal - this.value;
806
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
807
+ }
808
+ /** Smoothstep-eased value for perceptually smooth transitions */
809
+ get eased() {
810
+ const v = this.value;
811
+ return v * v * (3 - 2 * v);
812
+ }
813
+ };
814
+ }
815
+ });
816
+
638
817
  // src/engine/createEngine.ts
639
818
  var createEngine_exports = {};
640
819
  __export(createEngine_exports, {
@@ -652,9 +831,21 @@ function createEngine({
652
831
  let orderRevealEnabled = true;
653
832
  let activeBookIndex = -1;
654
833
  let orderRevealStrength = 0;
834
+ let flyToActive = false;
835
+ let flyToTargetLon = 0;
836
+ let flyToTargetLat = 0;
837
+ let flyToTargetFov = ENGINE_CONFIG.minFov;
838
+ const FLY_TO_SPEED = 0.04;
839
+ let currentFilter = null;
840
+ let filterStrength = 0;
841
+ let filterTestamentIndex = -1;
842
+ let filterDivisionIndex = -1;
843
+ let filterBookIndex = -1;
655
844
  const hoverCooldowns = /* @__PURE__ */ new Map();
656
845
  const COOLDOWN_MS = 2e3;
657
846
  const bookIdToIndex = /* @__PURE__ */ new Map();
847
+ const testamentToIndex = /* @__PURE__ */ new Map();
848
+ const divisionToIndex = /* @__PURE__ */ new Map();
658
849
  const renderer = new THREE5.WebGLRenderer({ antialias: true, alpha: false });
659
850
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
660
851
  renderer.setSize(container.clientWidth, container.clientHeight);
@@ -704,68 +895,53 @@ function createEngine({
704
895
  function mix(a, b, t) {
705
896
  return a * (1 - t) + b * t;
706
897
  }
707
- function getBlendFactor(fov) {
708
- if (fov <= ENGINE_CONFIG.blendStart) return 0;
709
- if (fov >= ENGINE_CONFIG.blendEnd) return 1;
710
- let t = (fov - ENGINE_CONFIG.blendStart) / (ENGINE_CONFIG.blendEnd - ENGINE_CONFIG.blendStart);
711
- return t * t * (3 - 2 * t);
898
+ let currentProjection = PROJECTIONS.blended();
899
+ function syncProjectionState() {
900
+ if (currentProjection instanceof BlendedProjection) {
901
+ currentProjection.setFov(state.fov);
902
+ globalUniforms.uBlend.value = currentProjection.getBlend();
903
+ }
904
+ globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
712
905
  }
713
906
  function updateUniforms() {
714
- const blend = getBlendFactor(state.fov);
715
- globalUniforms.uBlend.value = blend;
907
+ syncProjectionState();
716
908
  const fovRad = state.fov * Math.PI / 180;
717
- const scaleLinear = 1 / Math.tan(fovRad / 2);
718
- const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
719
- globalUniforms.uScale.value = mix(scaleLinear, scaleStereo, blend);
720
- globalUniforms.uAspect.value = camera.aspect;
909
+ let scale = currentProjection.getScale(fovRad);
910
+ const aspect = camera.aspect;
911
+ if (currentConfig?.fitProjection) {
912
+ if (aspect > 1) {
913
+ scale /= aspect;
914
+ }
915
+ }
916
+ globalUniforms.uScale.value = scale;
917
+ globalUniforms.uAspect.value = aspect;
721
918
  camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
722
919
  camera.updateProjectionMatrix();
723
920
  }
724
921
  function getMouseViewVector(fovDeg, aspectRatio) {
725
- const blend = getBlendFactor(fovDeg);
922
+ syncProjectionState();
726
923
  const fovRad = fovDeg * Math.PI / 180;
727
924
  const uvX = mouseNDC.x * aspectRatio;
728
925
  const uvY = mouseNDC.y;
729
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
730
- const halfHeightLinear = Math.tan(fovRad / 2);
731
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
732
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
733
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
734
- const theta = mix(theta_lin, theta_str, blend);
735
- const phi = Math.atan2(uvY, uvX);
736
- const sinTheta = Math.sin(theta);
737
- const cosTheta = Math.cos(theta);
738
- return new THREE5.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
926
+ const v = currentProjection.inverse(uvX, uvY, fovRad);
927
+ return new THREE5.Vector3(v.x, v.y, v.z).normalize();
739
928
  }
740
929
  function getMouseWorldVector(pixelX, pixelY, width, height) {
741
930
  const aspect = width / height;
742
931
  const ndcX = pixelX / width * 2 - 1;
743
932
  const ndcY = -(pixelY / height) * 2 + 1;
744
- const blend = getBlendFactor(state.fov);
933
+ syncProjectionState();
745
934
  const fovRad = state.fov * Math.PI / 180;
746
- const uvX = ndcX * aspect;
747
- const uvY = ndcY;
748
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
749
- const halfHeightLinear = Math.tan(fovRad / 2);
750
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
751
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
752
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
753
- const theta = mix(theta_lin, theta_str, blend);
754
- const phi = Math.atan2(uvY, uvX);
755
- const sinTheta = Math.sin(theta);
756
- const cosTheta = Math.cos(theta);
757
- const vView = new THREE5.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
935
+ const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
936
+ const vView = new THREE5.Vector3(v.x, v.y, v.z).normalize();
758
937
  return vView.applyQuaternion(camera.quaternion);
759
938
  }
760
939
  function smartProjectJS(worldPos) {
761
940
  const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
762
941
  const dir = viewPos.clone().normalize();
763
- const zLinear = Math.max(0.01, -dir.z);
764
- const kStereo = 2 / (1 - dir.z);
765
- const kLinear = 1 / zLinear;
766
- const blend = globalUniforms.uBlend.value;
767
- const k = mix(kLinear, kStereo, blend);
768
- return { x: k * dir.x, y: k * dir.y, z: dir.z };
942
+ const result = currentProjection.forward(dir);
943
+ if (!result) return { x: 0, y: 0, z: dir.z };
944
+ return result;
769
945
  }
770
946
  const groundGroup = new THREE5.Group();
771
947
  scene.add(groundGroup);
@@ -775,10 +951,8 @@ function createEngine({
775
951
  const geometry = new THREE5.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
776
952
  const material = createSmartMaterial({
777
953
  uniforms: {
778
- color: { value: new THREE5.Color(131587) },
779
- // Very dark almost black
780
- fogColor: { value: new THREE5.Color(331812) }
781
- // Matches atmosphere bot color
954
+ color: { value: new THREE5.Color(65794) },
955
+ fogColor: { value: new THREE5.Color(663098) }
782
956
  },
783
957
  vertexShaderBody: `
784
958
  varying vec3 vPos;
@@ -804,24 +978,30 @@ function createEngine({
804
978
  // Procedural Horizon (Mountains)
805
979
  float angle = atan(vPos.z, vPos.x);
806
980
 
807
- // Simple FBM-like terrain
981
+ // FBM-like terrain with increased amplitude
808
982
  float h = 0.0;
809
- h += sin(angle * 6.0) * 20.0;
810
- h += sin(angle * 13.0 + 1.0) * 10.0;
811
- h += sin(angle * 29.0 + 2.0) * 5.0;
812
- h += sin(angle * 63.0 + 4.0) * 2.0;
813
-
814
- // Base horizon offset (lift slightly)
815
- float terrainHeight = h + 10.0;
816
-
983
+ h += sin(angle * 6.0) * 35.0;
984
+ h += sin(angle * 13.0 + 1.0) * 18.0;
985
+ h += sin(angle * 29.0 + 2.0) * 8.0;
986
+ h += sin(angle * 63.0 + 4.0) * 3.0;
987
+ h += sin(angle * 97.0 + 5.0) * 1.5;
988
+
989
+ float terrainHeight = h + 12.0;
990
+
817
991
  if (vPos.y > terrainHeight) discard;
818
-
819
- // Atmospheric Haze / Fog on the ground
820
- // Mix ground color with fog color based on vertical height (fade into horizon)
821
- // Closer to horizon (higher y) -> more fog
822
- float fogFactor = smoothstep(-100.0, terrainHeight, vPos.y);
823
- vec3 finalCol = mix(color, fogColor, fogFactor * 0.5);
824
-
992
+
993
+ // Atmospheric rim glow just below terrain peaks
994
+ float rimDist = terrainHeight - vPos.y;
995
+ float rim = exp(-rimDist * 0.15) * 0.4;
996
+ vec3 rimColor = fogColor * 1.5;
997
+
998
+ // Atmospheric haze \u2014 stronger near horizon
999
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1000
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1001
+
1002
+ // Add rim glow near terrain peaks
1003
+ finalCol += rimColor * rim;
1004
+
825
1005
  gl_FragColor = vec4(finalCol, 1.0);
826
1006
  }
827
1007
  `,
@@ -859,19 +1039,25 @@ function createEngine({
859
1039
 
860
1040
  // Altitude angle (Y is up)
861
1041
  float h = normalize(vWorldNormal).y;
862
-
863
- // Gradient Logic
864
- // 1. Base gradient from Horizon to Zenith
865
- float t = smoothstep(-0.1, 0.5, h);
866
-
1042
+
1043
+ // 1. Base gradient from Horizon to Zenith (wider range)
1044
+ float t = smoothstep(-0.15, 0.7, h);
1045
+
867
1046
  // Non-linear mix for realistic sky falloff
868
- // Zenith darkness adjustment
869
1047
  vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
870
-
871
- // 2. Horizon Glow Band (Simulate scattering/haze layer)
872
- float horizonBand = exp(-15.0 * abs(h - 0.02)); // Sharp peak near 0
1048
+
1049
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
1050
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1051
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
1052
+
1053
+ // 3. Primary horizon glow band (wider than before)
1054
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
873
1055
  skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
874
1056
 
1057
+ // 4. Warm secondary glow (light pollution / sodium scatter)
1058
+ float warmGlow = exp(-8.0 * abs(h));
1059
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1060
+
875
1061
  gl_FragColor = vec4(skyColor, 1.0);
876
1062
  }
877
1063
  `,
@@ -909,7 +1095,24 @@ function createEngine({
909
1095
  positions.push(x, y, z);
910
1096
  const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
911
1097
  sizes.push(size);
912
- colors.push(1, 1, 1);
1098
+ const temp = Math.random();
1099
+ let cr, cg, cb;
1100
+ if (temp < 0.15) {
1101
+ cr = 0.7 + temp * 2;
1102
+ cg = 0.8 + temp;
1103
+ cb = 1;
1104
+ } else if (temp < 0.6) {
1105
+ const t = (temp - 0.15) / 0.45;
1106
+ cr = 1;
1107
+ cg = 1 - t * 0.1;
1108
+ cb = 1 - t * 0.3;
1109
+ } else {
1110
+ const t = (temp - 0.6) / 0.4;
1111
+ cr = 1;
1112
+ cg = 0.85 - t * 0.35;
1113
+ cb = 0.7 - t * 0.35;
1114
+ }
1115
+ colors.push(cr, cg, cb);
913
1116
  }
914
1117
  geometry.setAttribute("position", new THREE5.Float32BufferAttribute(positions, 3));
915
1118
  geometry.setAttribute("size", new THREE5.Float32BufferAttribute(sizes, 1));
@@ -917,51 +1120,60 @@ function createEngine({
917
1120
  const material = createSmartMaterial({
918
1121
  uniforms: {
919
1122
  pixelRatio: { value: renderer.getPixelRatio() },
920
- uScale: globalUniforms.uScale
1123
+ uScale: globalUniforms.uScale,
1124
+ uTime: globalUniforms.uTime
921
1125
  },
922
1126
  vertexShaderBody: `
923
- attribute float size;
924
- attribute vec3 color;
925
- varying vec3 vColor;
926
- uniform float pixelRatio;
927
-
1127
+ attribute float size;
1128
+ attribute vec3 color;
1129
+ varying vec3 vColor;
1130
+ uniform float pixelRatio;
1131
+
928
1132
  uniform float uAtmExtinction;
1133
+ uniform float uAtmTwinkle;
1134
+ uniform float uTime;
929
1135
 
930
- void main() {
1136
+ void main() {
931
1137
  vec3 nPos = normalize(position);
932
1138
  float altitude = nPos.y;
933
-
934
- // Simple Extinction & Horizon Fade
1139
+
1140
+ // Extinction & Horizon Fade
935
1141
  float horizonFade = smoothstep(-0.1, 0.1, altitude);
936
1142
  float airmass = 1.0 / (max(0.05, altitude + 0.05));
937
1143
  float extinction = exp(-uAtmExtinction * 0.15 * airmass);
938
1144
 
939
- // Boost intensity significantly (3.0x)
940
- vColor = color * 3.0 * extinction * horizonFade;
1145
+ // Scintillation (twinkling) \u2014 stronger near horizon
1146
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1147
+ float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1148
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
941
1149
 
942
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
943
- gl_Position = smartProject(mvPosition);
944
- vScreenPos = gl_Position.xy / gl_Position.w;
945
-
946
- // Non-linear scale with zoom to keep stars looking like points
947
- // pow(uScale, 0.5) prevents them from getting too large at low FOV
948
- float zoomScale = pow(uScale, 0.5);
949
-
950
- gl_PointSize = size * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade;
1150
+ vColor = color * 3.0 * extinction * horizonFade * scintillation;
1151
+
1152
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1153
+ gl_Position = smartProject(mvPosition);
1154
+ vScreenPos = gl_Position.xy / gl_Position.w;
1155
+
1156
+ float zoomScale = pow(uScale, 0.5);
1157
+ float perceptualSize = pow(size, 0.55);
1158
+ gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
951
1159
  }
952
1160
  `,
953
1161
  fragmentShader: `
954
- varying vec3 vColor;
955
- void main() {
956
- vec2 coord = gl_PointCoord - vec2(0.5);
957
- float dist = length(coord) * 2.0;
958
- if (dist > 1.0) discard;
959
- float alphaMask = getMaskAlpha();
960
- if (alphaMask < 0.01) discard;
961
-
962
- // Sharp falloff for intense point look
963
- float alpha = exp(-4.0 * dist * dist);
964
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1162
+ varying vec3 vColor;
1163
+ void main() {
1164
+ vec2 coord = gl_PointCoord - vec2(0.5);
1165
+ float d = length(coord) * 2.0;
1166
+ if (d > 1.0) discard;
1167
+ float alphaMask = getMaskAlpha();
1168
+ if (alphaMask < 0.01) discard;
1169
+
1170
+ // Stellarium-style: sharp core + soft glow
1171
+ float core = smoothstep(0.8, 0.4, d);
1172
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1173
+ float k = core + glow;
1174
+
1175
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.5);
1176
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
965
1177
  }
966
1178
  `,
967
1179
  transparent: true,
@@ -1034,6 +1246,9 @@ function createEngine({
1034
1246
  let constellationLines = null;
1035
1247
  let boundaryLines = null;
1036
1248
  let starPoints = null;
1249
+ const linesFader = new Fader(0.4);
1250
+ const artFader = new Fader(0.5);
1251
+ let lastTickTime = 0;
1037
1252
  function clearRoot() {
1038
1253
  for (const child of [...root.children]) {
1039
1254
  root.remove(child);
@@ -1104,6 +1319,8 @@ function createEngine({
1104
1319
  function buildFromModel(model, cfg) {
1105
1320
  clearRoot();
1106
1321
  bookIdToIndex.clear();
1322
+ testamentToIndex.clear();
1323
+ divisionToIndex.clear();
1107
1324
  scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5.Color(cfg.background) : new THREE5.Color(0);
1108
1325
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1109
1326
  const laidOut = computeLayoutPositions(model, layoutCfg);
@@ -1137,6 +1354,8 @@ function createEngine({
1137
1354
  const starPhases = [];
1138
1355
  const starBookIndices = [];
1139
1356
  const starChapterIndices = [];
1357
+ const starTestamentIndices = [];
1358
+ const starDivisionIndices = [];
1140
1359
  const SPECTRAL_COLORS = [
1141
1360
  new THREE5.Color(14544639),
1142
1361
  // O - Blueish White
@@ -1194,12 +1413,32 @@ function createEngine({
1194
1413
  let cIdx = 0;
1195
1414
  if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1196
1415
  starChapterIndices.push(cIdx);
1416
+ let tIdx = -1;
1417
+ if (n.meta?.testament) {
1418
+ const tName = n.meta.testament;
1419
+ if (!testamentToIndex.has(tName)) {
1420
+ testamentToIndex.set(tName, testamentToIndex.size + 1);
1421
+ }
1422
+ tIdx = testamentToIndex.get(tName);
1423
+ }
1424
+ starTestamentIndices.push(tIdx);
1425
+ let dIdx = -1;
1426
+ if (n.meta?.division) {
1427
+ const dName = n.meta.division;
1428
+ if (!divisionToIndex.has(dName)) {
1429
+ divisionToIndex.set(dName, divisionToIndex.size + 1);
1430
+ }
1431
+ dIdx = divisionToIndex.get(dName);
1432
+ }
1433
+ starDivisionIndices.push(dIdx);
1197
1434
  }
1198
1435
  if (n.level === 1 || n.level === 2 || n.level === 3) {
1199
1436
  let color = "#ffffff";
1200
1437
  if (n.level === 1) color = "#38bdf8";
1201
- else if (n.level === 2) color = "#cbd5e1";
1202
- else if (n.level === 3) color = "#94a3b8";
1438
+ else if (n.level === 2) {
1439
+ const bookKey = n.meta?.bookKey;
1440
+ color = bookKey && cfg.labelColors?.[bookKey] || "#cbd5e1";
1441
+ } else if (n.level === 3) color = "#94a3b8";
1203
1442
  let labelText = n.label;
1204
1443
  if (n.level === 3 && n.meta?.chapter) {
1205
1444
  labelText = String(n.meta.chapter);
@@ -1280,6 +1519,8 @@ function createEngine({
1280
1519
  starGeo.setAttribute("phase", new THREE5.Float32BufferAttribute(starPhases, 1));
1281
1520
  starGeo.setAttribute("bookIndex", new THREE5.Float32BufferAttribute(starBookIndices, 1));
1282
1521
  starGeo.setAttribute("chapterIndex", new THREE5.Float32BufferAttribute(starChapterIndices, 1));
1522
+ starGeo.setAttribute("testamentIndex", new THREE5.Float32BufferAttribute(starTestamentIndices, 1));
1523
+ starGeo.setAttribute("divisionIndex", new THREE5.Float32BufferAttribute(starDivisionIndices, 1));
1283
1524
  const starMat = createSmartMaterial({
1284
1525
  uniforms: {
1285
1526
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1292,7 +1533,12 @@ function createEngine({
1292
1533
  ORDER_REVEAL_CONFIG.pulseDuration,
1293
1534
  ORDER_REVEAL_CONFIG.delayPerChapter,
1294
1535
  ORDER_REVEAL_CONFIG.pulseAmplitude
1295
- ) }
1536
+ ) },
1537
+ uFilterTestamentIndex: { value: -1 },
1538
+ uFilterDivisionIndex: { value: -1 },
1539
+ uFilterBookIndex: { value: -1 },
1540
+ uFilterStrength: { value: 0 },
1541
+ uFilterDimFactor: { value: 0.08 }
1296
1542
  },
1297
1543
  vertexShaderBody: `
1298
1544
  attribute float size;
@@ -1300,10 +1546,12 @@ function createEngine({
1300
1546
  attribute float phase;
1301
1547
  attribute float bookIndex;
1302
1548
  attribute float chapterIndex;
1549
+ attribute float testamentIndex;
1550
+ attribute float divisionIndex;
1551
+
1552
+ varying vec3 vColor;
1553
+ uniform float pixelRatio;
1303
1554
 
1304
- varying vec3 vColor;
1305
- uniform float pixelRatio;
1306
-
1307
1555
  uniform float uTime;
1308
1556
  uniform float uAtmExtinction;
1309
1557
  uniform float uAtmTwinkle;
@@ -1313,6 +1561,12 @@ function createEngine({
1313
1561
  uniform float uGlobalDimFactor;
1314
1562
  uniform vec3 uPulseParams;
1315
1563
 
1564
+ uniform float uFilterTestamentIndex;
1565
+ uniform float uFilterDivisionIndex;
1566
+ uniform float uFilterBookIndex;
1567
+ uniform float uFilterStrength;
1568
+ uniform float uFilterDimFactor;
1569
+
1316
1570
  void main() {
1317
1571
  vec3 nPos = normalize(position);
1318
1572
 
@@ -1347,8 +1601,21 @@ function createEngine({
1347
1601
 
1348
1602
  float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1349
1603
 
1604
+ // --- Hierarchy Filter ---
1605
+ float filtered = 0.0;
1606
+ if (uFilterTestamentIndex >= 0.0) {
1607
+ filtered = 1.0 - step(0.5, 1.0 - abs(testamentIndex - uFilterTestamentIndex));
1608
+ }
1609
+ if (uFilterDivisionIndex >= 0.0 && filtered < 0.5) {
1610
+ filtered = 1.0 - step(0.5, 1.0 - abs(divisionIndex - uFilterDivisionIndex));
1611
+ }
1612
+ if (uFilterBookIndex >= 0.0 && filtered < 0.5) {
1613
+ filtered = 1.0 - step(0.5, 1.0 - abs(bookIndex - uFilterBookIndex));
1614
+ }
1615
+ float filterDim = mix(1.0, uFilterDimFactor, uFilterStrength * filtered);
1616
+
1350
1617
  vec3 baseColor = color * extinction * horizonFade * scintillation;
1351
- vColor = baseColor * dimFactor;
1618
+ vColor = baseColor * dimFactor * filterDim;
1352
1619
  vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1353
1620
 
1354
1621
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
@@ -1356,7 +1623,8 @@ function createEngine({
1356
1623
  vScreenPos = gl_Position.xy / gl_Position.w;
1357
1624
 
1358
1625
  float sizeBoost = 1.0 + activePulse * 0.8;
1359
- gl_PointSize = (size * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade;
1626
+ float perceptualSize = pow(size, 0.55);
1627
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
1360
1628
  }
1361
1629
  `,
1362
1630
  fragmentShader: `
@@ -1369,15 +1637,14 @@ function createEngine({
1369
1637
  float alphaMask = getMaskAlpha();
1370
1638
  if (alphaMask < 0.01) discard;
1371
1639
 
1372
- float dd = d * d;
1373
- // Stellarium Profile
1374
- float core = exp(-20.0 * dd);
1375
- float halo = exp(-4.0 * dd);
1376
-
1377
- vec3 cCore = vec3(1.0) * core * 1.5;
1378
- vec3 cHalo = vColor * halo * 0.6;
1379
-
1380
- gl_FragColor = vec4((cCore + cHalo) * alphaMask, 1.0);
1640
+ // Stellarium-style dual-layer: sharp core + soft glow
1641
+ float core = smoothstep(0.8, 0.4, d);
1642
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1643
+ float k = core + glow;
1644
+
1645
+ // White-hot core blending into coloured halo
1646
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1647
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
1381
1648
  }
1382
1649
  `,
1383
1650
  transparent: true,
@@ -1411,17 +1678,89 @@ function createEngine({
1411
1678
  }
1412
1679
  }
1413
1680
  if (linePoints.length > 0) {
1681
+ const quadPositions = [];
1682
+ const quadUvs = [];
1683
+ const quadIndices = [];
1684
+ const lineWidth = 8;
1685
+ for (let i = 0; i < linePoints.length; i += 6) {
1686
+ const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1687
+ const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
1688
+ const dx = bx - ax, dy = by - ay, dz = bz - az;
1689
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1690
+ if (len < 1e-3) continue;
1691
+ let px = dy * 0 - dz * 1, py = dz * 0 - dx * 0, pz = dx * 1 - dy * 0;
1692
+ const pLen = Math.sqrt(px * px + py * py + pz * pz);
1693
+ if (pLen < 1e-3) {
1694
+ px = 1;
1695
+ py = 0;
1696
+ pz = 0;
1697
+ } else {
1698
+ px /= pLen;
1699
+ py /= pLen;
1700
+ pz /= pLen;
1701
+ }
1702
+ const hw = lineWidth;
1703
+ const baseIdx = quadPositions.length / 3;
1704
+ quadPositions.push(ax - px * hw, ay - py * hw, az - pz * hw);
1705
+ quadUvs.push(0, -1);
1706
+ quadPositions.push(ax + px * hw, ay + py * hw, az + pz * hw);
1707
+ quadUvs.push(0, 1);
1708
+ quadPositions.push(bx - px * hw, by - py * hw, bz - pz * hw);
1709
+ quadUvs.push(1, -1);
1710
+ quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1711
+ quadUvs.push(1, 1);
1712
+ quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1713
+ }
1414
1714
  const lineGeo = new THREE5.BufferGeometry();
1415
- lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(linePoints, 3));
1715
+ lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(quadPositions, 3));
1716
+ lineGeo.setAttribute("lineUv", new THREE5.Float32BufferAttribute(quadUvs, 2));
1717
+ lineGeo.setIndex(quadIndices);
1416
1718
  const lineMat = createSmartMaterial({
1417
- uniforms: { color: { value: new THREE5.Color(11193599) } },
1418
- 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; }`,
1419
- fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.4 * alphaMask); }`,
1719
+ uniforms: {
1720
+ color: { value: new THREE5.Color(11193599) },
1721
+ uLineWidth: { value: 1.5 },
1722
+ uGlowIntensity: { value: 0.3 }
1723
+ },
1724
+ vertexShaderBody: `
1725
+ attribute vec2 lineUv;
1726
+ varying vec2 vLineUv;
1727
+ void main() {
1728
+ vLineUv = lineUv;
1729
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1730
+ gl_Position = smartProject(mvPosition);
1731
+ vScreenPos = gl_Position.xy / gl_Position.w;
1732
+ }
1733
+ `,
1734
+ fragmentShader: `
1735
+ uniform vec3 color;
1736
+ uniform float uLineWidth;
1737
+ uniform float uGlowIntensity;
1738
+ varying vec2 vLineUv;
1739
+ void main() {
1740
+ float alphaMask = getMaskAlpha();
1741
+ if (alphaMask < 0.01) discard;
1742
+
1743
+ float dist = abs(vLineUv.y);
1744
+
1745
+ // Anti-aliased core line
1746
+ float hw = uLineWidth * 0.05;
1747
+ float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1748
+
1749
+ // Soft glow extending outward
1750
+ float glow = (1.0 - dist) * uGlowIntensity;
1751
+
1752
+ float alpha = max(glow, base);
1753
+ if (alpha < 0.005) discard;
1754
+
1755
+ gl_FragColor = vec4(color, alpha * alphaMask);
1756
+ }
1757
+ `,
1420
1758
  transparent: true,
1421
1759
  depthWrite: false,
1422
- blending: THREE5.AdditiveBlending
1760
+ blending: THREE5.AdditiveBlending,
1761
+ side: THREE5.DoubleSide
1423
1762
  });
1424
- constellationLines = new THREE5.LineSegments(lineGeo, lineMat);
1763
+ constellationLines = new THREE5.Mesh(lineGeo, lineMat);
1425
1764
  constellationLines.frustumCulled = false;
1426
1765
  root.add(constellationLines);
1427
1766
  }
@@ -1593,8 +1932,15 @@ function createEngine({
1593
1932
  let lastAppliedLon = void 0;
1594
1933
  let lastAppliedLat = void 0;
1595
1934
  let lastBackdropCount = void 0;
1935
+ function setProjection(id) {
1936
+ const factory = PROJECTIONS[id];
1937
+ if (!factory) return;
1938
+ currentProjection = factory();
1939
+ updateUniforms();
1940
+ }
1596
1941
  function setConfig(cfg) {
1597
1942
  currentConfig = cfg;
1943
+ if (cfg.projection) setProjection(cfg.projection);
1598
1944
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1599
1945
  state.lon = cfg.camera.lon;
1600
1946
  state.targetLon = cfg.camera.lon;
@@ -1684,6 +2030,15 @@ function createEngine({
1684
2030
  Object.assign(arr, state.tempArrangement);
1685
2031
  return arr;
1686
2032
  }
2033
+ function isNodeFiltered(node) {
2034
+ if (!currentFilter) return false;
2035
+ const meta = node.meta;
2036
+ if (!meta) return false;
2037
+ if (currentFilter.testament && meta.testament !== currentFilter.testament) return true;
2038
+ if (currentFilter.division && meta.division !== currentFilter.division) return true;
2039
+ if (currentFilter.bookKey && meta.bookKey !== currentFilter.bookKey) return true;
2040
+ return false;
2041
+ }
1687
2042
  function pick(ev) {
1688
2043
  const rect = renderer.domElement.getBoundingClientRect();
1689
2044
  const mX = ev.clientX - rect.left;
@@ -1698,10 +2053,10 @@ function createEngine({
1698
2053
  let minLabelDist = 40;
1699
2054
  for (const item of dynamicLabels) {
1700
2055
  if (!item.obj.visible) continue;
2056
+ if (isNodeFiltered(item.node)) continue;
1701
2057
  const pWorld = item.obj.position;
1702
2058
  const pProj = smartProjectJS(pWorld);
1703
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1704
- if (isBehind) continue;
2059
+ if (currentProjection.isClipped(pProj.z)) continue;
1705
2060
  const xNDC = pProj.x * uScale / uAspect;
1706
2061
  const yNDC = pProj.y * uScale;
1707
2062
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1723,8 +2078,7 @@ function createEngine({
1723
2078
  if (!item.mesh.visible) continue;
1724
2079
  const pWorld = item.mesh.position;
1725
2080
  const pProj = smartProjectJS(pWorld);
1726
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1727
- if (isBehind) continue;
2081
+ if (currentProjection.isClipped(pProj.z)) continue;
1728
2082
  const uniforms = item.material.uniforms;
1729
2083
  if (!uniforms || !uniforms.uSize) continue;
1730
2084
  const uSize = uniforms.uSize.value;
@@ -1773,12 +2127,16 @@ function createEngine({
1773
2127
  const id = starIndexToId[pointHit.index];
1774
2128
  if (id) {
1775
2129
  const node = nodeById.get(id);
1776
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2130
+ if (node && !isNodeFiltered(node)) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
1777
2131
  }
1778
2132
  }
1779
2133
  }
1780
2134
  return void 0;
1781
2135
  }
2136
+ function onWindowBlur() {
2137
+ isMouseInWindow = false;
2138
+ edgeHoverStart = 0;
2139
+ }
1782
2140
  function onMouseDown(e) {
1783
2141
  state.lastMouseX = e.clientX;
1784
2142
  state.lastMouseY = e.clientY;
@@ -1816,6 +2174,7 @@ function createEngine({
1816
2174
  }
1817
2175
  return;
1818
2176
  }
2177
+ flyToActive = false;
1819
2178
  state.dragMode = "camera";
1820
2179
  state.isDragging = true;
1821
2180
  state.velocityX = 0;
@@ -1874,11 +2233,13 @@ function createEngine({
1874
2233
  state.lastMouseX = e.clientX;
1875
2234
  state.lastMouseY = e.clientY;
1876
2235
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2236
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2237
+ const latFactor = 1 - rotLock * rotLock;
1877
2238
  state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1878
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2239
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1879
2240
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1880
2241
  state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1881
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2242
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1882
2243
  state.lon = state.targetLon;
1883
2244
  state.lat = state.targetLat;
1884
2245
  } else {
@@ -1912,6 +2273,9 @@ function createEngine({
1912
2273
  }
1913
2274
  }
1914
2275
  function onMouseUp(e) {
2276
+ const dx = e.clientX - state.lastMouseX;
2277
+ const dy = e.clientY - state.lastMouseY;
2278
+ const movedDist = Math.sqrt(dx * dx + dy * dy);
1915
2279
  if (state.dragMode === "node") {
1916
2280
  const fullArr = getFullArrangement();
1917
2281
  handlers.onArrangementChange?.(fullArr);
@@ -1924,6 +2288,17 @@ function createEngine({
1924
2288
  state.isDragging = false;
1925
2289
  state.dragMode = "none";
1926
2290
  document.body.style.cursor = "default";
2291
+ if (movedDist < 5) {
2292
+ const hit = pick(e);
2293
+ if (hit) {
2294
+ handlers.onSelect?.(hit.node);
2295
+ constellationLayer.setFocused(hit.node.id);
2296
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2297
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2298
+ } else {
2299
+ setFocusedBook(null);
2300
+ }
2301
+ }
1927
2302
  } else {
1928
2303
  const hit = pick(e);
1929
2304
  if (hit) {
@@ -1938,6 +2313,7 @@ function createEngine({
1938
2313
  }
1939
2314
  function onWheel(e) {
1940
2315
  e.preventDefault();
2316
+ flyToActive = false;
1941
2317
  const aspect = container.clientWidth / container.clientHeight;
1942
2318
  renderer.domElement.getBoundingClientRect();
1943
2319
  const vBefore = getMouseViewVector(state.fov, aspect);
@@ -1948,6 +2324,17 @@ function createEngine({
1948
2324
  updateUniforms();
1949
2325
  const vAfter = getMouseViewVector(state.fov, aspect);
1950
2326
  const quaternion = new THREE5.Quaternion().setFromUnitVectors(vAfter, vBefore);
2327
+ const dampStartFov = 40;
2328
+ const dampEndFov = 120;
2329
+ let spinAmount = 1;
2330
+ if (state.fov > dampStartFov) {
2331
+ const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2332
+ spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2333
+ }
2334
+ if (spinAmount < 0.999) {
2335
+ const identityQuat = new THREE5.Quaternion();
2336
+ quaternion.slerp(identityQuat, 1 - spinAmount);
2337
+ }
1951
2338
  const y = Math.sin(state.lat);
1952
2339
  const r = Math.cos(state.lat);
1953
2340
  const x = r * Math.sin(state.lon);
@@ -1996,9 +2383,8 @@ function createEngine({
1996
2383
  el.addEventListener("mouseenter", () => {
1997
2384
  isMouseInWindow = true;
1998
2385
  });
1999
- el.addEventListener("mouseleave", () => {
2000
- isMouseInWindow = false;
2001
- });
2386
+ el.addEventListener("mouseleave", onWindowBlur);
2387
+ window.addEventListener("blur", onWindowBlur);
2002
2388
  raf = requestAnimationFrame(tick);
2003
2389
  }
2004
2390
  function tick() {
@@ -2027,6 +2413,17 @@ function createEngine({
2027
2413
  if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2028
2414
  }
2029
2415
  }
2416
+ const filterTarget = currentFilter ? 1 : 0;
2417
+ filterStrength = mix(filterStrength, filterTarget, 0.1);
2418
+ if (filterStrength > 1e-3 || filterTarget > 0) {
2419
+ if (starPoints && starPoints.material) {
2420
+ const m = starPoints.material;
2421
+ if (m.uniforms.uFilterTestamentIndex) m.uniforms.uFilterTestamentIndex.value = filterTestamentIndex;
2422
+ if (m.uniforms.uFilterDivisionIndex) m.uniforms.uFilterDivisionIndex.value = filterDivisionIndex;
2423
+ if (m.uniforms.uFilterBookIndex) m.uniforms.uFilterBookIndex.value = filterBookIndex;
2424
+ if (m.uniforms.uFilterStrength) m.uniforms.uFilterStrength.value = filterStrength;
2425
+ }
2426
+ }
2030
2427
  let panX = 0;
2031
2428
  let panY = 0;
2032
2429
  if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
@@ -2058,12 +2455,28 @@ function createEngine({
2058
2455
  } else {
2059
2456
  edgeHoverStart = 0;
2060
2457
  }
2458
+ if (flyToActive && !state.isDragging) {
2459
+ state.lon = mix(state.lon, flyToTargetLon, FLY_TO_SPEED);
2460
+ state.lat = mix(state.lat, flyToTargetLat, FLY_TO_SPEED);
2461
+ state.fov = mix(state.fov, flyToTargetFov, FLY_TO_SPEED);
2462
+ state.targetLon = state.lon;
2463
+ state.targetLat = state.lat;
2464
+ state.velocityX = 0;
2465
+ state.velocityY = 0;
2466
+ handlers.onFovChange?.(state.fov);
2467
+ if (Math.abs(state.lon - flyToTargetLon) < 1e-4 && Math.abs(state.lat - flyToTargetLat) < 1e-4 && Math.abs(state.fov - flyToTargetFov) < 0.05) {
2468
+ flyToActive = false;
2469
+ state.lon = flyToTargetLon;
2470
+ state.lat = flyToTargetLat;
2471
+ state.fov = flyToTargetFov;
2472
+ }
2473
+ }
2061
2474
  if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2062
2475
  state.lon += panX;
2063
2476
  state.lat += panY;
2064
2477
  state.targetLon = state.lon;
2065
2478
  state.targetLat = state.lat;
2066
- } else if (!state.isDragging) {
2479
+ } else if (!state.isDragging && !flyToActive) {
2067
2480
  state.lon += state.velocityX;
2068
2481
  state.lat += state.velocityY;
2069
2482
  state.velocityX *= ENGINE_CONFIG.inertiaDamping;
@@ -2084,13 +2497,30 @@ function createEngine({
2084
2497
  camera.updateMatrixWorld();
2085
2498
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
2086
2499
  updateUniforms();
2087
- constellationLayer.update(state.fov, currentConfig?.showConstellationArt ?? false);
2500
+ const nowSec = now / 1e3;
2501
+ const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2502
+ lastTickTime = nowSec;
2503
+ linesFader.target = currentConfig?.showConstellationLines ?? false;
2504
+ linesFader.update(dt);
2505
+ artFader.target = currentConfig?.showConstellationArt ?? false;
2506
+ artFader.update(dt);
2507
+ constellationLayer.update(state.fov, artFader.eased > 0.01);
2508
+ if (artFader.eased < 1) {
2509
+ constellationLayer.setGlobalOpacity?.(artFader.eased);
2510
+ }
2088
2511
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2089
2512
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2090
2513
  const DIVISION_THRESHOLD = 60;
2091
2514
  const showDivisions = state.fov > DIVISION_THRESHOLD;
2092
2515
  if (constellationLines) {
2093
- constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2516
+ constellationLines.visible = linesFader.eased > 0.01;
2517
+ if (constellationLines.visible && constellationLines.material) {
2518
+ const mat = constellationLines.material;
2519
+ if (mat.uniforms?.color) {
2520
+ mat.uniforms.color.value.setHex(11193599);
2521
+ mat.opacity = linesFader.eased;
2522
+ }
2523
+ }
2094
2524
  }
2095
2525
  if (boundaryLines) {
2096
2526
  boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
@@ -2111,8 +2541,7 @@ function createEngine({
2111
2541
  const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2112
2542
  const showChapterLabels = currentConfig?.showChapterLabels === true;
2113
2543
  const showGroupLabels = currentConfig?.showGroupLabels === true;
2114
- const showBooks = state.fov < 120;
2115
- const showChapters = state.fov < 70;
2544
+ const showChapters = state.fov < 45;
2116
2545
  for (const item of dynamicLabels) {
2117
2546
  const uniforms = item.obj.material.uniforms;
2118
2547
  const level = item.node.level;
@@ -2133,11 +2562,6 @@ function createEngine({
2133
2562
  item.obj.visible = uniforms.uAlpha.value > 0.01;
2134
2563
  continue;
2135
2564
  }
2136
- if (level === 2 && !showBooks && item.node.id !== state.draggedNodeId) {
2137
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2138
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2139
- continue;
2140
- }
2141
2565
  if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2142
2566
  uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2143
2567
  item.obj.visible = uniforms.uAlpha.value > 0.01;
@@ -2170,8 +2594,8 @@ function createEngine({
2170
2594
  const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2171
2595
  if (l.level === 1) {
2172
2596
  let rot = 0;
2173
- const blend = globalUniforms.uBlend.value;
2174
- if (blend > 0.5) {
2597
+ const isWideAngle = currentProjection.id !== "perspective";
2598
+ if (isWideAngle) {
2175
2599
  const dx = l.sX - screenW / 2;
2176
2600
  const dy = l.sY - screenH / 2;
2177
2601
  rot = Math.atan2(-dy, -dx) - Math.PI / 2;
@@ -2179,7 +2603,7 @@ function createEngine({
2179
2603
  l.uniforms.uAngle.value = THREE5.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2180
2604
  }
2181
2605
  if (l.level === 2) {
2182
- if (showBooks || isSpecial) {
2606
+ {
2183
2607
  target2 = 1;
2184
2608
  occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2185
2609
  }
@@ -2201,6 +2625,17 @@ function createEngine({
2201
2625
  }
2202
2626
  }
2203
2627
  }
2628
+ if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2629
+ const node = l.item.node;
2630
+ if (node.level === 3) {
2631
+ target2 = 0;
2632
+ } else if (node.level === 2 || node.level === 2.5) {
2633
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2634
+ if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2635
+ target2 = 0;
2636
+ }
2637
+ }
2638
+ }
2204
2639
  l.uniforms.uAlpha.value = THREE5.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2205
2640
  l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2206
2641
  }
@@ -2215,6 +2650,8 @@ function createEngine({
2215
2650
  window.removeEventListener("mousemove", onMouseMove);
2216
2651
  window.removeEventListener("mouseup", onMouseUp);
2217
2652
  el.removeEventListener("wheel", onWheel);
2653
+ el.removeEventListener("mouseleave", onWindowBlur);
2654
+ window.removeEventListener("blur", onWindowBlur);
2218
2655
  }
2219
2656
  function dispose() {
2220
2657
  stop();
@@ -2235,7 +2672,30 @@ function createEngine({
2235
2672
  function setOrderRevealEnabled(enabled) {
2236
2673
  orderRevealEnabled = enabled;
2237
2674
  }
2238
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled };
2675
+ function flyTo(nodeId, targetFov) {
2676
+ const node = nodeById.get(nodeId);
2677
+ if (!node) return;
2678
+ const pos = getPosition(node).normalize();
2679
+ flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2680
+ flyToTargetLon = Math.atan2(pos.x, -pos.z);
2681
+ flyToTargetFov = targetFov ?? ENGINE_CONFIG.minFov;
2682
+ flyToActive = true;
2683
+ state.velocityX = 0;
2684
+ state.velocityY = 0;
2685
+ }
2686
+ function setHierarchyFilter(filter) {
2687
+ currentFilter = filter;
2688
+ if (filter) {
2689
+ filterTestamentIndex = filter.testament && testamentToIndex.has(filter.testament) ? testamentToIndex.get(filter.testament) : -1;
2690
+ filterDivisionIndex = filter.division && divisionToIndex.has(filter.division) ? divisionToIndex.get(filter.division) : -1;
2691
+ filterBookIndex = filter.bookKey && bookIdToIndex.has(`B:${filter.bookKey}`) ? bookIdToIndex.get(`B:${filter.bookKey}`) : -1;
2692
+ } else {
2693
+ filterTestamentIndex = -1;
2694
+ filterDivisionIndex = -1;
2695
+ filterBookIndex = -1;
2696
+ }
2697
+ }
2698
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
2239
2699
  }
2240
2700
  var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
2241
2701
  var init_createEngine = __esm({
@@ -2243,16 +2703,18 @@ var init_createEngine = __esm({
2243
2703
  init_layout();
2244
2704
  init_materials();
2245
2705
  init_ConstellationArtworkLayer();
2706
+ init_projections();
2707
+ init_fader();
2246
2708
  ENGINE_CONFIG = {
2247
2709
  minFov: 10,
2248
- maxFov: 165,
2249
- defaultFov: 80,
2710
+ maxFov: 135,
2711
+ defaultFov: 50,
2250
2712
  dragSpeed: 125e-5,
2251
2713
  inertiaDamping: 0.92,
2252
- blendStart: 60,
2253
- blendEnd: 165,
2254
- zenithStartFov: 110,
2255
- zenithStrength: 0.02,
2714
+ blendStart: 35,
2715
+ blendEnd: 83,
2716
+ zenithStartFov: 75,
2717
+ zenithStrength: 0.15,
2256
2718
  horizonLockStrength: 0.05,
2257
2719
  edgePanThreshold: 0.15,
2258
2720
  edgePanMaxSpeed: 0.02,
@@ -2274,7 +2736,10 @@ var StarMap = forwardRef(
2274
2736
  getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2275
2737
  setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2276
2738
  setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2277
- setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled)
2739
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled),
2740
+ setHierarchyFilter: (filter) => engineRef.current?.setHierarchyFilter?.(filter),
2741
+ flyTo: (nodeId, targetFov) => engineRef.current?.flyTo?.(nodeId, targetFov),
2742
+ setProjection: (id) => engineRef.current?.setProjection?.(id)
2278
2743
  }));
2279
2744
  useEffect(() => {
2280
2745
  let disposed = false;
@@ -31551,6 +32016,9 @@ function generateArrangement(bible, options = {}) {
31551
32016
  return arrangement;
31552
32017
  }
31553
32018
 
31554
- export { StarMap, bibleToSceneModel, defaultGenerateOptions, default_stars_default as defaultStars, generateArrangement };
32019
+ // src/index.ts
32020
+ init_projections();
32021
+
32022
+ export { PROJECTIONS, StarMap, bibleToSceneModel, defaultGenerateOptions, default_stars_default as defaultStars, generateArrangement };
31555
32023
  //# sourceMappingURL=index.js.map
31556
32024
  //# sourceMappingURL=index.js.map