@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.cjs CHANGED
@@ -341,45 +341,60 @@ var init_shaders = __esm({
341
341
  uniform float uScale;
342
342
  uniform float uAspect;
343
343
  uniform float uBlend;
344
+ uniform int uProjectionType;
344
345
 
345
346
  vec4 smartProject(vec4 viewPos) {
346
347
  vec3 dir = normalize(viewPos.xyz);
347
348
  float dist = length(viewPos.xyz);
348
- float zLinear = max(0.01, -dir.z);
349
- float kStereo = 2.0 / (1.0 - dir.z);
350
- float kLinear = 1.0 / zLinear;
351
- float k = mix(kLinear, kStereo, uBlend);
352
- vec2 projected = vec2(k * dir.x, k * dir.y);
353
- projected *= uScale;
354
- projected.x /= uAspect;
355
- float zMetric = -1.0 + (dist / 15000.0);
356
-
349
+ float k;
350
+
357
351
  // Radial Clipping: Push clipped points off-screen in their natural direction
358
352
  // to prevent lines "darting" across the center.
359
353
  vec2 escapeDir = (length(dir.xy) > 0.0001) ? normalize(dir.xy) : vec2(1.0, 1.0);
360
354
  vec2 escapePos = escapeDir * 10000.0;
361
355
 
362
- // Clip backward facing points in fisheye mode
363
- if (uBlend > 0.5 && dir.z > 0.4) return vec4(escapePos, 10.0, 1.0);
364
- // Clip very close points in linear mode
365
- if (uBlend < 0.1 && dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
366
-
356
+ if (uProjectionType == 0) {
357
+ // Perspective
358
+ if (dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
359
+ k = 1.0 / max(0.01, -dir.z);
360
+ } else if (uProjectionType == 1) {
361
+ // Stereographic \u2014 tighter clip to prevent stretch near singularity
362
+ if (dir.z > 0.1) return vec4(escapePos, 10.0, 1.0);
363
+ k = 2.0 / (1.0 - dir.z);
364
+ } else {
365
+ // Blended (auto-blend behavior)
366
+ float zLinear = max(0.01, -dir.z);
367
+ float kStereo = 2.0 / (1.0 - dir.z);
368
+ float kLinear = 1.0 / zLinear;
369
+ k = mix(kLinear, kStereo, uBlend);
370
+
371
+ // Tighter clip threshold that scales with blend factor
372
+ float clipZ = mix(-0.1, 0.1, uBlend);
373
+ if (dir.z > clipZ) return vec4(escapePos, 10.0, 1.0);
374
+ }
375
+
376
+ vec2 projected = vec2(k * dir.x, k * dir.y);
377
+ projected *= uScale;
378
+ projected.x /= uAspect;
379
+ float zMetric = -1.0 + (dist / 15000.0);
380
+
367
381
  return vec4(projected, zMetric, 1.0);
368
382
  }
369
383
  `;
370
384
  MASK_CHUNK = `
371
385
  uniform float uAspect;
372
386
  uniform float uBlend;
387
+ uniform int uProjectionType;
373
388
  varying vec2 vScreenPos;
374
389
  float getMaskAlpha() {
375
- if (uBlend < 0.1) return 1.0;
390
+ // No artificial circular mask \u2014 the horizon, atmosphere, and ground
391
+ // define the dome boundary naturally (as Stellarium does).
392
+ // Only apply a minimal edge softening to catch stray back-face artifacts.
376
393
  vec2 p = vScreenPos;
377
394
  p.x *= uAspect;
378
395
  float dist = length(p);
379
- float t = smoothstep(0.75, 1.0, uBlend);
380
- float currentRadius = mix(2.5, 1.0, t);
381
- float edgeSoftness = mix(0.5, 0.02, t);
382
- return 1.0 - smoothstep(currentRadius - edgeSoftness, currentRadius, dist);
396
+ // Gentle falloff only at extreme screen edges (beyond NDC ~1.8)
397
+ return 1.0 - smoothstep(1.8, 2.0, dist);
383
398
  }
384
399
  `;
385
400
  }
@@ -412,13 +427,15 @@ var init_materials = __esm({
412
427
  uScale: { value: 1 },
413
428
  uAspect: { value: 1 },
414
429
  uBlend: { value: 0 },
430
+ uProjectionType: { value: 2 },
431
+ // 0=perspective, 1=stereographic, 2=blended
415
432
  uTime: { value: 0 },
416
433
  // Atmosphere Settings
417
434
  uAtmGlow: { value: 1 },
418
435
  uAtmDark: { value: 0.6 },
419
436
  uAtmExtinction: { value: 4 },
420
437
  uAtmTwinkle: { value: 0.8 },
421
- uColorHorizon: { value: new THREE5__namespace.Color(2768476) },
438
+ uColorHorizon: { value: new THREE5__namespace.Color(3825292) },
422
439
  uColorZenith: { value: new THREE5__namespace.Color(132104) }
423
440
  };
424
441
  }
@@ -617,6 +634,10 @@ var init_ConstellationArtworkLayer = __esm({
617
634
  this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
618
635
  });
619
636
  }
637
+ _globalOpacity = 1;
638
+ setGlobalOpacity(v) {
639
+ this._globalOpacity = v;
640
+ }
620
641
  update(fov, showArt) {
621
642
  this.root.visible = showArt;
622
643
  if (!showArt) return;
@@ -631,7 +652,7 @@ var init_ConstellationArtworkLayer = __esm({
631
652
  const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
632
653
  opacity = THREE5__namespace.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
633
654
  }
634
- opacity = Math.min(Math.max(opacity, 0), 1);
655
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
635
656
  item.material.uniforms.uOpacity.value = opacity;
636
657
  }
637
658
  }
@@ -657,6 +678,164 @@ var init_ConstellationArtworkLayer = __esm({
657
678
  }
658
679
  });
659
680
 
681
+ // src/engine/projections.ts
682
+ var PerspectiveProjection, StereographicProjection, BlendedProjection; exports.PROJECTIONS = void 0;
683
+ var init_projections = __esm({
684
+ "src/engine/projections.ts"() {
685
+ PerspectiveProjection = class {
686
+ id = "perspective";
687
+ label = "Perspective";
688
+ maxFov = 160;
689
+ glslProjectionType = 0;
690
+ forward(dir) {
691
+ if (dir.z > -0.1) return null;
692
+ const k = 1 / Math.max(0.01, -dir.z);
693
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
694
+ }
695
+ inverse(uvX, uvY, fovRad) {
696
+ const halfHeight = Math.tan(fovRad / 2);
697
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
698
+ const theta = Math.atan(r * halfHeight);
699
+ const phi = Math.atan2(uvY, uvX);
700
+ const sinT = Math.sin(theta);
701
+ return {
702
+ x: sinT * Math.cos(phi),
703
+ y: sinT * Math.sin(phi),
704
+ z: -Math.cos(theta)
705
+ };
706
+ }
707
+ getScale(fovRad) {
708
+ return 1 / Math.tan(fovRad / 2);
709
+ }
710
+ isClipped(dirZ) {
711
+ return dirZ > -0.1;
712
+ }
713
+ };
714
+ StereographicProjection = class {
715
+ id = "stereographic";
716
+ label = "Stereographic";
717
+ maxFov = 360;
718
+ glslProjectionType = 1;
719
+ forward(dir) {
720
+ if (dir.z > 0.4) return null;
721
+ const k = 2 / (1 - dir.z);
722
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
723
+ }
724
+ inverse(uvX, uvY, fovRad) {
725
+ const halfHeight = 2 * Math.tan(fovRad / 4);
726
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
727
+ const theta = 2 * Math.atan(r * halfHeight / 2);
728
+ const phi = Math.atan2(uvY, uvX);
729
+ const sinT = Math.sin(theta);
730
+ return {
731
+ x: sinT * Math.cos(phi),
732
+ y: sinT * Math.sin(phi),
733
+ z: -Math.cos(theta)
734
+ };
735
+ }
736
+ getScale(fovRad) {
737
+ return 1 / (2 * Math.tan(fovRad / 4));
738
+ }
739
+ isClipped(dirZ) {
740
+ return dirZ > 0.4;
741
+ }
742
+ };
743
+ BlendedProjection = class {
744
+ id = "blended";
745
+ label = "Blended (Auto)";
746
+ maxFov = 165;
747
+ glslProjectionType = 2;
748
+ /** FOV thresholds for blend transition (degrees) */
749
+ blendStart = 40;
750
+ blendEnd = 100;
751
+ /** Current blend factor, updated via setFov() */
752
+ blend = 0;
753
+ /** Call this each frame / when FOV changes so forward/inverse stay in sync */
754
+ setFov(fovDeg) {
755
+ if (fovDeg <= this.blendStart) {
756
+ this.blend = 0;
757
+ return;
758
+ }
759
+ if (fovDeg >= this.blendEnd) {
760
+ this.blend = 1;
761
+ return;
762
+ }
763
+ const t = (fovDeg - this.blendStart) / (this.blendEnd - this.blendStart);
764
+ this.blend = t * t * (3 - 2 * t);
765
+ }
766
+ getBlend() {
767
+ return this.blend;
768
+ }
769
+ forward(dir) {
770
+ if (this.blend > 0.5 && dir.z > 0.4) return null;
771
+ if (this.blend < 0.1 && dir.z > -0.1) return null;
772
+ const kLinear = 1 / Math.max(0.01, -dir.z);
773
+ const kStereo = 2 / (1 - dir.z);
774
+ const k = kLinear * (1 - this.blend) + kStereo * this.blend;
775
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
776
+ }
777
+ inverse(uvX, uvY, fovRad) {
778
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
779
+ const halfHeightLin = Math.tan(fovRad / 2);
780
+ const thetaLin = Math.atan(r * halfHeightLin);
781
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
782
+ const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
783
+ const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
784
+ const phi = Math.atan2(uvY, uvX);
785
+ const sinT = Math.sin(theta);
786
+ return {
787
+ x: sinT * Math.cos(phi),
788
+ y: sinT * Math.sin(phi),
789
+ z: -Math.cos(theta)
790
+ };
791
+ }
792
+ getScale(fovRad) {
793
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
794
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
795
+ return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
796
+ }
797
+ isClipped(dirZ) {
798
+ if (this.blend > 0.5) return dirZ > 0.4;
799
+ if (this.blend < 0.1) return dirZ > -0.1;
800
+ return false;
801
+ }
802
+ };
803
+ exports.PROJECTIONS = {
804
+ perspective: () => new PerspectiveProjection(),
805
+ stereographic: () => new StereographicProjection(),
806
+ blended: () => new BlendedProjection()
807
+ };
808
+ }
809
+ });
810
+
811
+ // src/engine/fader.ts
812
+ var Fader;
813
+ var init_fader = __esm({
814
+ "src/engine/fader.ts"() {
815
+ Fader = class {
816
+ target = false;
817
+ value = 0;
818
+ duration;
819
+ constructor(duration = 0.3) {
820
+ this.duration = duration;
821
+ }
822
+ update(dt) {
823
+ const goal = this.target ? 1 : 0;
824
+ if (this.value === goal) return;
825
+ const speed = 1 / this.duration;
826
+ const step = speed * dt;
827
+ const diff = goal - this.value;
828
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
829
+ }
830
+ /** Smoothstep-eased value for perceptually smooth transitions */
831
+ get eased() {
832
+ const v = this.value;
833
+ return v * v * (3 - 2 * v);
834
+ }
835
+ };
836
+ }
837
+ });
838
+
660
839
  // src/engine/createEngine.ts
661
840
  var createEngine_exports = {};
662
841
  __export(createEngine_exports, {
@@ -674,9 +853,21 @@ function createEngine({
674
853
  let orderRevealEnabled = true;
675
854
  let activeBookIndex = -1;
676
855
  let orderRevealStrength = 0;
856
+ let flyToActive = false;
857
+ let flyToTargetLon = 0;
858
+ let flyToTargetLat = 0;
859
+ let flyToTargetFov = ENGINE_CONFIG.minFov;
860
+ const FLY_TO_SPEED = 0.04;
861
+ let currentFilter = null;
862
+ let filterStrength = 0;
863
+ let filterTestamentIndex = -1;
864
+ let filterDivisionIndex = -1;
865
+ let filterBookIndex = -1;
677
866
  const hoverCooldowns = /* @__PURE__ */ new Map();
678
867
  const COOLDOWN_MS = 2e3;
679
868
  const bookIdToIndex = /* @__PURE__ */ new Map();
869
+ const testamentToIndex = /* @__PURE__ */ new Map();
870
+ const divisionToIndex = /* @__PURE__ */ new Map();
680
871
  const renderer = new THREE5__namespace.WebGLRenderer({ antialias: true, alpha: false });
681
872
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
682
873
  renderer.setSize(container.clientWidth, container.clientHeight);
@@ -726,68 +917,53 @@ function createEngine({
726
917
  function mix(a, b, t) {
727
918
  return a * (1 - t) + b * t;
728
919
  }
729
- function getBlendFactor(fov) {
730
- if (fov <= ENGINE_CONFIG.blendStart) return 0;
731
- if (fov >= ENGINE_CONFIG.blendEnd) return 1;
732
- let t = (fov - ENGINE_CONFIG.blendStart) / (ENGINE_CONFIG.blendEnd - ENGINE_CONFIG.blendStart);
733
- return t * t * (3 - 2 * t);
920
+ let currentProjection = exports.PROJECTIONS.blended();
921
+ function syncProjectionState() {
922
+ if (currentProjection instanceof BlendedProjection) {
923
+ currentProjection.setFov(state.fov);
924
+ globalUniforms.uBlend.value = currentProjection.getBlend();
925
+ }
926
+ globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
734
927
  }
735
928
  function updateUniforms() {
736
- const blend = getBlendFactor(state.fov);
737
- globalUniforms.uBlend.value = blend;
929
+ syncProjectionState();
738
930
  const fovRad = state.fov * Math.PI / 180;
739
- const scaleLinear = 1 / Math.tan(fovRad / 2);
740
- const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
741
- globalUniforms.uScale.value = mix(scaleLinear, scaleStereo, blend);
742
- globalUniforms.uAspect.value = camera.aspect;
931
+ let scale = currentProjection.getScale(fovRad);
932
+ const aspect = camera.aspect;
933
+ if (currentConfig?.fitProjection) {
934
+ if (aspect > 1) {
935
+ scale /= aspect;
936
+ }
937
+ }
938
+ globalUniforms.uScale.value = scale;
939
+ globalUniforms.uAspect.value = aspect;
743
940
  camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
744
941
  camera.updateProjectionMatrix();
745
942
  }
746
943
  function getMouseViewVector(fovDeg, aspectRatio) {
747
- const blend = getBlendFactor(fovDeg);
944
+ syncProjectionState();
748
945
  const fovRad = fovDeg * Math.PI / 180;
749
946
  const uvX = mouseNDC.x * aspectRatio;
750
947
  const uvY = mouseNDC.y;
751
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
752
- const halfHeightLinear = Math.tan(fovRad / 2);
753
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
754
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
755
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
756
- const theta = mix(theta_lin, theta_str, blend);
757
- const phi = Math.atan2(uvY, uvX);
758
- const sinTheta = Math.sin(theta);
759
- const cosTheta = Math.cos(theta);
760
- return new THREE5__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
948
+ const v = currentProjection.inverse(uvX, uvY, fovRad);
949
+ return new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
761
950
  }
762
951
  function getMouseWorldVector(pixelX, pixelY, width, height) {
763
952
  const aspect = width / height;
764
953
  const ndcX = pixelX / width * 2 - 1;
765
954
  const ndcY = -(pixelY / height) * 2 + 1;
766
- const blend = getBlendFactor(state.fov);
955
+ syncProjectionState();
767
956
  const fovRad = state.fov * Math.PI / 180;
768
- const uvX = ndcX * aspect;
769
- const uvY = ndcY;
770
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
771
- const halfHeightLinear = Math.tan(fovRad / 2);
772
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
773
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
774
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
775
- const theta = mix(theta_lin, theta_str, blend);
776
- const phi = Math.atan2(uvY, uvX);
777
- const sinTheta = Math.sin(theta);
778
- const cosTheta = Math.cos(theta);
779
- const vView = new THREE5__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
957
+ const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
958
+ const vView = new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
780
959
  return vView.applyQuaternion(camera.quaternion);
781
960
  }
782
961
  function smartProjectJS(worldPos) {
783
962
  const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
784
963
  const dir = viewPos.clone().normalize();
785
- const zLinear = Math.max(0.01, -dir.z);
786
- const kStereo = 2 / (1 - dir.z);
787
- const kLinear = 1 / zLinear;
788
- const blend = globalUniforms.uBlend.value;
789
- const k = mix(kLinear, kStereo, blend);
790
- return { x: k * dir.x, y: k * dir.y, z: dir.z };
964
+ const result = currentProjection.forward(dir);
965
+ if (!result) return { x: 0, y: 0, z: dir.z };
966
+ return result;
791
967
  }
792
968
  const groundGroup = new THREE5__namespace.Group();
793
969
  scene.add(groundGroup);
@@ -797,10 +973,8 @@ function createEngine({
797
973
  const geometry = new THREE5__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
798
974
  const material = createSmartMaterial({
799
975
  uniforms: {
800
- color: { value: new THREE5__namespace.Color(131587) },
801
- // Very dark almost black
802
- fogColor: { value: new THREE5__namespace.Color(331812) }
803
- // Matches atmosphere bot color
976
+ color: { value: new THREE5__namespace.Color(65794) },
977
+ fogColor: { value: new THREE5__namespace.Color(663098) }
804
978
  },
805
979
  vertexShaderBody: `
806
980
  varying vec3 vPos;
@@ -826,24 +1000,30 @@ function createEngine({
826
1000
  // Procedural Horizon (Mountains)
827
1001
  float angle = atan(vPos.z, vPos.x);
828
1002
 
829
- // Simple FBM-like terrain
1003
+ // FBM-like terrain with increased amplitude
830
1004
  float h = 0.0;
831
- h += sin(angle * 6.0) * 20.0;
832
- h += sin(angle * 13.0 + 1.0) * 10.0;
833
- h += sin(angle * 29.0 + 2.0) * 5.0;
834
- h += sin(angle * 63.0 + 4.0) * 2.0;
835
-
836
- // Base horizon offset (lift slightly)
837
- float terrainHeight = h + 10.0;
838
-
1005
+ h += sin(angle * 6.0) * 35.0;
1006
+ h += sin(angle * 13.0 + 1.0) * 18.0;
1007
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1008
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1009
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1010
+
1011
+ float terrainHeight = h + 12.0;
1012
+
839
1013
  if (vPos.y > terrainHeight) discard;
840
-
841
- // Atmospheric Haze / Fog on the ground
842
- // Mix ground color with fog color based on vertical height (fade into horizon)
843
- // Closer to horizon (higher y) -> more fog
844
- float fogFactor = smoothstep(-100.0, terrainHeight, vPos.y);
845
- vec3 finalCol = mix(color, fogColor, fogFactor * 0.5);
846
-
1014
+
1015
+ // Atmospheric rim glow just below terrain peaks
1016
+ float rimDist = terrainHeight - vPos.y;
1017
+ float rim = exp(-rimDist * 0.15) * 0.4;
1018
+ vec3 rimColor = fogColor * 1.5;
1019
+
1020
+ // Atmospheric haze \u2014 stronger near horizon
1021
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1022
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1023
+
1024
+ // Add rim glow near terrain peaks
1025
+ finalCol += rimColor * rim;
1026
+
847
1027
  gl_FragColor = vec4(finalCol, 1.0);
848
1028
  }
849
1029
  `,
@@ -881,19 +1061,25 @@ function createEngine({
881
1061
 
882
1062
  // Altitude angle (Y is up)
883
1063
  float h = normalize(vWorldNormal).y;
884
-
885
- // Gradient Logic
886
- // 1. Base gradient from Horizon to Zenith
887
- float t = smoothstep(-0.1, 0.5, h);
888
-
1064
+
1065
+ // 1. Base gradient from Horizon to Zenith (wider range)
1066
+ float t = smoothstep(-0.15, 0.7, h);
1067
+
889
1068
  // Non-linear mix for realistic sky falloff
890
- // Zenith darkness adjustment
891
1069
  vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
892
-
893
- // 2. Horizon Glow Band (Simulate scattering/haze layer)
894
- float horizonBand = exp(-15.0 * abs(h - 0.02)); // Sharp peak near 0
1070
+
1071
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
1072
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1073
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
1074
+
1075
+ // 3. Primary horizon glow band (wider than before)
1076
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
895
1077
  skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
896
1078
 
1079
+ // 4. Warm secondary glow (light pollution / sodium scatter)
1080
+ float warmGlow = exp(-8.0 * abs(h));
1081
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1082
+
897
1083
  gl_FragColor = vec4(skyColor, 1.0);
898
1084
  }
899
1085
  `,
@@ -931,7 +1117,24 @@ function createEngine({
931
1117
  positions.push(x, y, z);
932
1118
  const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
933
1119
  sizes.push(size);
934
- colors.push(1, 1, 1);
1120
+ const temp = Math.random();
1121
+ let cr, cg, cb;
1122
+ if (temp < 0.15) {
1123
+ cr = 0.7 + temp * 2;
1124
+ cg = 0.8 + temp;
1125
+ cb = 1;
1126
+ } else if (temp < 0.6) {
1127
+ const t = (temp - 0.15) / 0.45;
1128
+ cr = 1;
1129
+ cg = 1 - t * 0.1;
1130
+ cb = 1 - t * 0.3;
1131
+ } else {
1132
+ const t = (temp - 0.6) / 0.4;
1133
+ cr = 1;
1134
+ cg = 0.85 - t * 0.35;
1135
+ cb = 0.7 - t * 0.35;
1136
+ }
1137
+ colors.push(cr, cg, cb);
935
1138
  }
936
1139
  geometry.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(positions, 3));
937
1140
  geometry.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(sizes, 1));
@@ -939,51 +1142,60 @@ function createEngine({
939
1142
  const material = createSmartMaterial({
940
1143
  uniforms: {
941
1144
  pixelRatio: { value: renderer.getPixelRatio() },
942
- uScale: globalUniforms.uScale
1145
+ uScale: globalUniforms.uScale,
1146
+ uTime: globalUniforms.uTime
943
1147
  },
944
1148
  vertexShaderBody: `
945
- attribute float size;
946
- attribute vec3 color;
947
- varying vec3 vColor;
948
- uniform float pixelRatio;
949
-
1149
+ attribute float size;
1150
+ attribute vec3 color;
1151
+ varying vec3 vColor;
1152
+ uniform float pixelRatio;
1153
+
950
1154
  uniform float uAtmExtinction;
1155
+ uniform float uAtmTwinkle;
1156
+ uniform float uTime;
951
1157
 
952
- void main() {
1158
+ void main() {
953
1159
  vec3 nPos = normalize(position);
954
1160
  float altitude = nPos.y;
955
-
956
- // Simple Extinction & Horizon Fade
1161
+
1162
+ // Extinction & Horizon Fade
957
1163
  float horizonFade = smoothstep(-0.1, 0.1, altitude);
958
1164
  float airmass = 1.0 / (max(0.05, altitude + 0.05));
959
1165
  float extinction = exp(-uAtmExtinction * 0.15 * airmass);
960
1166
 
961
- // Boost intensity significantly (3.0x)
962
- vColor = color * 3.0 * extinction * horizonFade;
1167
+ // Scintillation (twinkling) \u2014 stronger near horizon
1168
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1169
+ float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1170
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
963
1171
 
964
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
965
- gl_Position = smartProject(mvPosition);
966
- vScreenPos = gl_Position.xy / gl_Position.w;
967
-
968
- // Non-linear scale with zoom to keep stars looking like points
969
- // pow(uScale, 0.5) prevents them from getting too large at low FOV
970
- float zoomScale = pow(uScale, 0.5);
971
-
972
- gl_PointSize = size * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade;
1172
+ vColor = color * 3.0 * extinction * horizonFade * scintillation;
1173
+
1174
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1175
+ gl_Position = smartProject(mvPosition);
1176
+ vScreenPos = gl_Position.xy / gl_Position.w;
1177
+
1178
+ float zoomScale = pow(uScale, 0.5);
1179
+ float perceptualSize = pow(size, 0.55);
1180
+ gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
973
1181
  }
974
1182
  `,
975
1183
  fragmentShader: `
976
- varying vec3 vColor;
977
- void main() {
978
- vec2 coord = gl_PointCoord - vec2(0.5);
979
- float dist = length(coord) * 2.0;
980
- if (dist > 1.0) discard;
981
- float alphaMask = getMaskAlpha();
982
- if (alphaMask < 0.01) discard;
983
-
984
- // Sharp falloff for intense point look
985
- float alpha = exp(-4.0 * dist * dist);
986
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1184
+ varying vec3 vColor;
1185
+ void main() {
1186
+ vec2 coord = gl_PointCoord - vec2(0.5);
1187
+ float d = length(coord) * 2.0;
1188
+ if (d > 1.0) discard;
1189
+ float alphaMask = getMaskAlpha();
1190
+ if (alphaMask < 0.01) discard;
1191
+
1192
+ // Stellarium-style: sharp core + soft glow
1193
+ float core = smoothstep(0.8, 0.4, d);
1194
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1195
+ float k = core + glow;
1196
+
1197
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.5);
1198
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
987
1199
  }
988
1200
  `,
989
1201
  transparent: true,
@@ -1056,6 +1268,9 @@ function createEngine({
1056
1268
  let constellationLines = null;
1057
1269
  let boundaryLines = null;
1058
1270
  let starPoints = null;
1271
+ const linesFader = new Fader(0.4);
1272
+ const artFader = new Fader(0.5);
1273
+ let lastTickTime = 0;
1059
1274
  function clearRoot() {
1060
1275
  for (const child of [...root.children]) {
1061
1276
  root.remove(child);
@@ -1126,6 +1341,8 @@ function createEngine({
1126
1341
  function buildFromModel(model, cfg) {
1127
1342
  clearRoot();
1128
1343
  bookIdToIndex.clear();
1344
+ testamentToIndex.clear();
1345
+ divisionToIndex.clear();
1129
1346
  scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5__namespace.Color(cfg.background) : new THREE5__namespace.Color(0);
1130
1347
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1131
1348
  const laidOut = computeLayoutPositions(model, layoutCfg);
@@ -1159,6 +1376,8 @@ function createEngine({
1159
1376
  const starPhases = [];
1160
1377
  const starBookIndices = [];
1161
1378
  const starChapterIndices = [];
1379
+ const starTestamentIndices = [];
1380
+ const starDivisionIndices = [];
1162
1381
  const SPECTRAL_COLORS = [
1163
1382
  new THREE5__namespace.Color(14544639),
1164
1383
  // O - Blueish White
@@ -1216,12 +1435,32 @@ function createEngine({
1216
1435
  let cIdx = 0;
1217
1436
  if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1218
1437
  starChapterIndices.push(cIdx);
1438
+ let tIdx = -1;
1439
+ if (n.meta?.testament) {
1440
+ const tName = n.meta.testament;
1441
+ if (!testamentToIndex.has(tName)) {
1442
+ testamentToIndex.set(tName, testamentToIndex.size + 1);
1443
+ }
1444
+ tIdx = testamentToIndex.get(tName);
1445
+ }
1446
+ starTestamentIndices.push(tIdx);
1447
+ let dIdx = -1;
1448
+ if (n.meta?.division) {
1449
+ const dName = n.meta.division;
1450
+ if (!divisionToIndex.has(dName)) {
1451
+ divisionToIndex.set(dName, divisionToIndex.size + 1);
1452
+ }
1453
+ dIdx = divisionToIndex.get(dName);
1454
+ }
1455
+ starDivisionIndices.push(dIdx);
1219
1456
  }
1220
1457
  if (n.level === 1 || n.level === 2 || n.level === 3) {
1221
1458
  let color = "#ffffff";
1222
1459
  if (n.level === 1) color = "#38bdf8";
1223
- else if (n.level === 2) color = "#cbd5e1";
1224
- else if (n.level === 3) color = "#94a3b8";
1460
+ else if (n.level === 2) {
1461
+ const bookKey = n.meta?.bookKey;
1462
+ color = bookKey && cfg.labelColors?.[bookKey] || "#cbd5e1";
1463
+ } else if (n.level === 3) color = "#94a3b8";
1225
1464
  let labelText = n.label;
1226
1465
  if (n.level === 3 && n.meta?.chapter) {
1227
1466
  labelText = String(n.meta.chapter);
@@ -1302,6 +1541,8 @@ function createEngine({
1302
1541
  starGeo.setAttribute("phase", new THREE5__namespace.Float32BufferAttribute(starPhases, 1));
1303
1542
  starGeo.setAttribute("bookIndex", new THREE5__namespace.Float32BufferAttribute(starBookIndices, 1));
1304
1543
  starGeo.setAttribute("chapterIndex", new THREE5__namespace.Float32BufferAttribute(starChapterIndices, 1));
1544
+ starGeo.setAttribute("testamentIndex", new THREE5__namespace.Float32BufferAttribute(starTestamentIndices, 1));
1545
+ starGeo.setAttribute("divisionIndex", new THREE5__namespace.Float32BufferAttribute(starDivisionIndices, 1));
1305
1546
  const starMat = createSmartMaterial({
1306
1547
  uniforms: {
1307
1548
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1314,7 +1555,12 @@ function createEngine({
1314
1555
  ORDER_REVEAL_CONFIG.pulseDuration,
1315
1556
  ORDER_REVEAL_CONFIG.delayPerChapter,
1316
1557
  ORDER_REVEAL_CONFIG.pulseAmplitude
1317
- ) }
1558
+ ) },
1559
+ uFilterTestamentIndex: { value: -1 },
1560
+ uFilterDivisionIndex: { value: -1 },
1561
+ uFilterBookIndex: { value: -1 },
1562
+ uFilterStrength: { value: 0 },
1563
+ uFilterDimFactor: { value: 0.08 }
1318
1564
  },
1319
1565
  vertexShaderBody: `
1320
1566
  attribute float size;
@@ -1322,10 +1568,12 @@ function createEngine({
1322
1568
  attribute float phase;
1323
1569
  attribute float bookIndex;
1324
1570
  attribute float chapterIndex;
1571
+ attribute float testamentIndex;
1572
+ attribute float divisionIndex;
1573
+
1574
+ varying vec3 vColor;
1575
+ uniform float pixelRatio;
1325
1576
 
1326
- varying vec3 vColor;
1327
- uniform float pixelRatio;
1328
-
1329
1577
  uniform float uTime;
1330
1578
  uniform float uAtmExtinction;
1331
1579
  uniform float uAtmTwinkle;
@@ -1335,6 +1583,12 @@ function createEngine({
1335
1583
  uniform float uGlobalDimFactor;
1336
1584
  uniform vec3 uPulseParams;
1337
1585
 
1586
+ uniform float uFilterTestamentIndex;
1587
+ uniform float uFilterDivisionIndex;
1588
+ uniform float uFilterBookIndex;
1589
+ uniform float uFilterStrength;
1590
+ uniform float uFilterDimFactor;
1591
+
1338
1592
  void main() {
1339
1593
  vec3 nPos = normalize(position);
1340
1594
 
@@ -1369,8 +1623,21 @@ function createEngine({
1369
1623
 
1370
1624
  float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1371
1625
 
1626
+ // --- Hierarchy Filter ---
1627
+ float filtered = 0.0;
1628
+ if (uFilterTestamentIndex >= 0.0) {
1629
+ filtered = 1.0 - step(0.5, 1.0 - abs(testamentIndex - uFilterTestamentIndex));
1630
+ }
1631
+ if (uFilterDivisionIndex >= 0.0 && filtered < 0.5) {
1632
+ filtered = 1.0 - step(0.5, 1.0 - abs(divisionIndex - uFilterDivisionIndex));
1633
+ }
1634
+ if (uFilterBookIndex >= 0.0 && filtered < 0.5) {
1635
+ filtered = 1.0 - step(0.5, 1.0 - abs(bookIndex - uFilterBookIndex));
1636
+ }
1637
+ float filterDim = mix(1.0, uFilterDimFactor, uFilterStrength * filtered);
1638
+
1372
1639
  vec3 baseColor = color * extinction * horizonFade * scintillation;
1373
- vColor = baseColor * dimFactor;
1640
+ vColor = baseColor * dimFactor * filterDim;
1374
1641
  vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1375
1642
 
1376
1643
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
@@ -1378,7 +1645,8 @@ function createEngine({
1378
1645
  vScreenPos = gl_Position.xy / gl_Position.w;
1379
1646
 
1380
1647
  float sizeBoost = 1.0 + activePulse * 0.8;
1381
- gl_PointSize = (size * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade;
1648
+ float perceptualSize = pow(size, 0.55);
1649
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
1382
1650
  }
1383
1651
  `,
1384
1652
  fragmentShader: `
@@ -1391,15 +1659,14 @@ function createEngine({
1391
1659
  float alphaMask = getMaskAlpha();
1392
1660
  if (alphaMask < 0.01) discard;
1393
1661
 
1394
- float dd = d * d;
1395
- // Stellarium Profile
1396
- float core = exp(-20.0 * dd);
1397
- float halo = exp(-4.0 * dd);
1398
-
1399
- vec3 cCore = vec3(1.0) * core * 1.5;
1400
- vec3 cHalo = vColor * halo * 0.6;
1401
-
1402
- gl_FragColor = vec4((cCore + cHalo) * alphaMask, 1.0);
1662
+ // Stellarium-style dual-layer: sharp core + soft glow
1663
+ float core = smoothstep(0.8, 0.4, d);
1664
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1665
+ float k = core + glow;
1666
+
1667
+ // White-hot core blending into coloured halo
1668
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1669
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
1403
1670
  }
1404
1671
  `,
1405
1672
  transparent: true,
@@ -1433,17 +1700,89 @@ function createEngine({
1433
1700
  }
1434
1701
  }
1435
1702
  if (linePoints.length > 0) {
1703
+ const quadPositions = [];
1704
+ const quadUvs = [];
1705
+ const quadIndices = [];
1706
+ const lineWidth = 8;
1707
+ for (let i = 0; i < linePoints.length; i += 6) {
1708
+ const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1709
+ const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
1710
+ const dx = bx - ax, dy = by - ay, dz = bz - az;
1711
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1712
+ if (len < 1e-3) continue;
1713
+ let px = dy * 0 - dz * 1, py = dz * 0 - dx * 0, pz = dx * 1 - dy * 0;
1714
+ const pLen = Math.sqrt(px * px + py * py + pz * pz);
1715
+ if (pLen < 1e-3) {
1716
+ px = 1;
1717
+ py = 0;
1718
+ pz = 0;
1719
+ } else {
1720
+ px /= pLen;
1721
+ py /= pLen;
1722
+ pz /= pLen;
1723
+ }
1724
+ const hw = lineWidth;
1725
+ const baseIdx = quadPositions.length / 3;
1726
+ quadPositions.push(ax - px * hw, ay - py * hw, az - pz * hw);
1727
+ quadUvs.push(0, -1);
1728
+ quadPositions.push(ax + px * hw, ay + py * hw, az + pz * hw);
1729
+ quadUvs.push(0, 1);
1730
+ quadPositions.push(bx - px * hw, by - py * hw, bz - pz * hw);
1731
+ quadUvs.push(1, -1);
1732
+ quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1733
+ quadUvs.push(1, 1);
1734
+ quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1735
+ }
1436
1736
  const lineGeo = new THREE5__namespace.BufferGeometry();
1437
- lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(linePoints, 3));
1737
+ lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(quadPositions, 3));
1738
+ lineGeo.setAttribute("lineUv", new THREE5__namespace.Float32BufferAttribute(quadUvs, 2));
1739
+ lineGeo.setIndex(quadIndices);
1438
1740
  const lineMat = createSmartMaterial({
1439
- uniforms: { color: { value: new THREE5__namespace.Color(11193599) } },
1440
- 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; }`,
1441
- fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.4 * alphaMask); }`,
1741
+ uniforms: {
1742
+ color: { value: new THREE5__namespace.Color(11193599) },
1743
+ uLineWidth: { value: 1.5 },
1744
+ uGlowIntensity: { value: 0.3 }
1745
+ },
1746
+ vertexShaderBody: `
1747
+ attribute vec2 lineUv;
1748
+ varying vec2 vLineUv;
1749
+ void main() {
1750
+ vLineUv = lineUv;
1751
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1752
+ gl_Position = smartProject(mvPosition);
1753
+ vScreenPos = gl_Position.xy / gl_Position.w;
1754
+ }
1755
+ `,
1756
+ fragmentShader: `
1757
+ uniform vec3 color;
1758
+ uniform float uLineWidth;
1759
+ uniform float uGlowIntensity;
1760
+ varying vec2 vLineUv;
1761
+ void main() {
1762
+ float alphaMask = getMaskAlpha();
1763
+ if (alphaMask < 0.01) discard;
1764
+
1765
+ float dist = abs(vLineUv.y);
1766
+
1767
+ // Anti-aliased core line
1768
+ float hw = uLineWidth * 0.05;
1769
+ float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1770
+
1771
+ // Soft glow extending outward
1772
+ float glow = (1.0 - dist) * uGlowIntensity;
1773
+
1774
+ float alpha = max(glow, base);
1775
+ if (alpha < 0.005) discard;
1776
+
1777
+ gl_FragColor = vec4(color, alpha * alphaMask);
1778
+ }
1779
+ `,
1442
1780
  transparent: true,
1443
1781
  depthWrite: false,
1444
- blending: THREE5__namespace.AdditiveBlending
1782
+ blending: THREE5__namespace.AdditiveBlending,
1783
+ side: THREE5__namespace.DoubleSide
1445
1784
  });
1446
- constellationLines = new THREE5__namespace.LineSegments(lineGeo, lineMat);
1785
+ constellationLines = new THREE5__namespace.Mesh(lineGeo, lineMat);
1447
1786
  constellationLines.frustumCulled = false;
1448
1787
  root.add(constellationLines);
1449
1788
  }
@@ -1615,8 +1954,15 @@ function createEngine({
1615
1954
  let lastAppliedLon = void 0;
1616
1955
  let lastAppliedLat = void 0;
1617
1956
  let lastBackdropCount = void 0;
1957
+ function setProjection(id) {
1958
+ const factory = exports.PROJECTIONS[id];
1959
+ if (!factory) return;
1960
+ currentProjection = factory();
1961
+ updateUniforms();
1962
+ }
1618
1963
  function setConfig(cfg) {
1619
1964
  currentConfig = cfg;
1965
+ if (cfg.projection) setProjection(cfg.projection);
1620
1966
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1621
1967
  state.lon = cfg.camera.lon;
1622
1968
  state.targetLon = cfg.camera.lon;
@@ -1706,6 +2052,15 @@ function createEngine({
1706
2052
  Object.assign(arr, state.tempArrangement);
1707
2053
  return arr;
1708
2054
  }
2055
+ function isNodeFiltered(node) {
2056
+ if (!currentFilter) return false;
2057
+ const meta = node.meta;
2058
+ if (!meta) return false;
2059
+ if (currentFilter.testament && meta.testament !== currentFilter.testament) return true;
2060
+ if (currentFilter.division && meta.division !== currentFilter.division) return true;
2061
+ if (currentFilter.bookKey && meta.bookKey !== currentFilter.bookKey) return true;
2062
+ return false;
2063
+ }
1709
2064
  function pick(ev) {
1710
2065
  const rect = renderer.domElement.getBoundingClientRect();
1711
2066
  const mX = ev.clientX - rect.left;
@@ -1720,10 +2075,10 @@ function createEngine({
1720
2075
  let minLabelDist = 40;
1721
2076
  for (const item of dynamicLabels) {
1722
2077
  if (!item.obj.visible) continue;
2078
+ if (isNodeFiltered(item.node)) continue;
1723
2079
  const pWorld = item.obj.position;
1724
2080
  const pProj = smartProjectJS(pWorld);
1725
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1726
- if (isBehind) continue;
2081
+ if (currentProjection.isClipped(pProj.z)) continue;
1727
2082
  const xNDC = pProj.x * uScale / uAspect;
1728
2083
  const yNDC = pProj.y * uScale;
1729
2084
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1745,8 +2100,7 @@ function createEngine({
1745
2100
  if (!item.mesh.visible) continue;
1746
2101
  const pWorld = item.mesh.position;
1747
2102
  const pProj = smartProjectJS(pWorld);
1748
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1749
- if (isBehind) continue;
2103
+ if (currentProjection.isClipped(pProj.z)) continue;
1750
2104
  const uniforms = item.material.uniforms;
1751
2105
  if (!uniforms || !uniforms.uSize) continue;
1752
2106
  const uSize = uniforms.uSize.value;
@@ -1795,12 +2149,16 @@ function createEngine({
1795
2149
  const id = starIndexToId[pointHit.index];
1796
2150
  if (id) {
1797
2151
  const node = nodeById.get(id);
1798
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2152
+ if (node && !isNodeFiltered(node)) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
1799
2153
  }
1800
2154
  }
1801
2155
  }
1802
2156
  return void 0;
1803
2157
  }
2158
+ function onWindowBlur() {
2159
+ isMouseInWindow = false;
2160
+ edgeHoverStart = 0;
2161
+ }
1804
2162
  function onMouseDown(e) {
1805
2163
  state.lastMouseX = e.clientX;
1806
2164
  state.lastMouseY = e.clientY;
@@ -1838,6 +2196,7 @@ function createEngine({
1838
2196
  }
1839
2197
  return;
1840
2198
  }
2199
+ flyToActive = false;
1841
2200
  state.dragMode = "camera";
1842
2201
  state.isDragging = true;
1843
2202
  state.velocityX = 0;
@@ -1896,11 +2255,13 @@ function createEngine({
1896
2255
  state.lastMouseX = e.clientX;
1897
2256
  state.lastMouseY = e.clientY;
1898
2257
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2258
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2259
+ const latFactor = 1 - rotLock * rotLock;
1899
2260
  state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1900
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2261
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1901
2262
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1902
2263
  state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1903
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2264
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1904
2265
  state.lon = state.targetLon;
1905
2266
  state.lat = state.targetLat;
1906
2267
  } else {
@@ -1934,6 +2295,9 @@ function createEngine({
1934
2295
  }
1935
2296
  }
1936
2297
  function onMouseUp(e) {
2298
+ const dx = e.clientX - state.lastMouseX;
2299
+ const dy = e.clientY - state.lastMouseY;
2300
+ const movedDist = Math.sqrt(dx * dx + dy * dy);
1937
2301
  if (state.dragMode === "node") {
1938
2302
  const fullArr = getFullArrangement();
1939
2303
  handlers.onArrangementChange?.(fullArr);
@@ -1946,6 +2310,17 @@ function createEngine({
1946
2310
  state.isDragging = false;
1947
2311
  state.dragMode = "none";
1948
2312
  document.body.style.cursor = "default";
2313
+ if (movedDist < 5) {
2314
+ const hit = pick(e);
2315
+ if (hit) {
2316
+ handlers.onSelect?.(hit.node);
2317
+ constellationLayer.setFocused(hit.node.id);
2318
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2319
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2320
+ } else {
2321
+ setFocusedBook(null);
2322
+ }
2323
+ }
1949
2324
  } else {
1950
2325
  const hit = pick(e);
1951
2326
  if (hit) {
@@ -1960,6 +2335,7 @@ function createEngine({
1960
2335
  }
1961
2336
  function onWheel(e) {
1962
2337
  e.preventDefault();
2338
+ flyToActive = false;
1963
2339
  const aspect = container.clientWidth / container.clientHeight;
1964
2340
  renderer.domElement.getBoundingClientRect();
1965
2341
  const vBefore = getMouseViewVector(state.fov, aspect);
@@ -1970,6 +2346,17 @@ function createEngine({
1970
2346
  updateUniforms();
1971
2347
  const vAfter = getMouseViewVector(state.fov, aspect);
1972
2348
  const quaternion = new THREE5__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
2349
+ const dampStartFov = 40;
2350
+ const dampEndFov = 120;
2351
+ let spinAmount = 1;
2352
+ if (state.fov > dampStartFov) {
2353
+ const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2354
+ spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2355
+ }
2356
+ if (spinAmount < 0.999) {
2357
+ const identityQuat = new THREE5__namespace.Quaternion();
2358
+ quaternion.slerp(identityQuat, 1 - spinAmount);
2359
+ }
1973
2360
  const y = Math.sin(state.lat);
1974
2361
  const r = Math.cos(state.lat);
1975
2362
  const x = r * Math.sin(state.lon);
@@ -2018,9 +2405,8 @@ function createEngine({
2018
2405
  el.addEventListener("mouseenter", () => {
2019
2406
  isMouseInWindow = true;
2020
2407
  });
2021
- el.addEventListener("mouseleave", () => {
2022
- isMouseInWindow = false;
2023
- });
2408
+ el.addEventListener("mouseleave", onWindowBlur);
2409
+ window.addEventListener("blur", onWindowBlur);
2024
2410
  raf = requestAnimationFrame(tick);
2025
2411
  }
2026
2412
  function tick() {
@@ -2049,6 +2435,17 @@ function createEngine({
2049
2435
  if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2050
2436
  }
2051
2437
  }
2438
+ const filterTarget = currentFilter ? 1 : 0;
2439
+ filterStrength = mix(filterStrength, filterTarget, 0.1);
2440
+ if (filterStrength > 1e-3 || filterTarget > 0) {
2441
+ if (starPoints && starPoints.material) {
2442
+ const m = starPoints.material;
2443
+ if (m.uniforms.uFilterTestamentIndex) m.uniforms.uFilterTestamentIndex.value = filterTestamentIndex;
2444
+ if (m.uniforms.uFilterDivisionIndex) m.uniforms.uFilterDivisionIndex.value = filterDivisionIndex;
2445
+ if (m.uniforms.uFilterBookIndex) m.uniforms.uFilterBookIndex.value = filterBookIndex;
2446
+ if (m.uniforms.uFilterStrength) m.uniforms.uFilterStrength.value = filterStrength;
2447
+ }
2448
+ }
2052
2449
  let panX = 0;
2053
2450
  let panY = 0;
2054
2451
  if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
@@ -2080,12 +2477,28 @@ function createEngine({
2080
2477
  } else {
2081
2478
  edgeHoverStart = 0;
2082
2479
  }
2480
+ if (flyToActive && !state.isDragging) {
2481
+ state.lon = mix(state.lon, flyToTargetLon, FLY_TO_SPEED);
2482
+ state.lat = mix(state.lat, flyToTargetLat, FLY_TO_SPEED);
2483
+ state.fov = mix(state.fov, flyToTargetFov, FLY_TO_SPEED);
2484
+ state.targetLon = state.lon;
2485
+ state.targetLat = state.lat;
2486
+ state.velocityX = 0;
2487
+ state.velocityY = 0;
2488
+ handlers.onFovChange?.(state.fov);
2489
+ if (Math.abs(state.lon - flyToTargetLon) < 1e-4 && Math.abs(state.lat - flyToTargetLat) < 1e-4 && Math.abs(state.fov - flyToTargetFov) < 0.05) {
2490
+ flyToActive = false;
2491
+ state.lon = flyToTargetLon;
2492
+ state.lat = flyToTargetLat;
2493
+ state.fov = flyToTargetFov;
2494
+ }
2495
+ }
2083
2496
  if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2084
2497
  state.lon += panX;
2085
2498
  state.lat += panY;
2086
2499
  state.targetLon = state.lon;
2087
2500
  state.targetLat = state.lat;
2088
- } else if (!state.isDragging) {
2501
+ } else if (!state.isDragging && !flyToActive) {
2089
2502
  state.lon += state.velocityX;
2090
2503
  state.lat += state.velocityY;
2091
2504
  state.velocityX *= ENGINE_CONFIG.inertiaDamping;
@@ -2106,13 +2519,30 @@ function createEngine({
2106
2519
  camera.updateMatrixWorld();
2107
2520
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
2108
2521
  updateUniforms();
2109
- constellationLayer.update(state.fov, currentConfig?.showConstellationArt ?? false);
2522
+ const nowSec = now / 1e3;
2523
+ const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2524
+ lastTickTime = nowSec;
2525
+ linesFader.target = currentConfig?.showConstellationLines ?? false;
2526
+ linesFader.update(dt);
2527
+ artFader.target = currentConfig?.showConstellationArt ?? false;
2528
+ artFader.update(dt);
2529
+ constellationLayer.update(state.fov, artFader.eased > 0.01);
2530
+ if (artFader.eased < 1) {
2531
+ constellationLayer.setGlobalOpacity?.(artFader.eased);
2532
+ }
2110
2533
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2111
2534
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2112
2535
  const DIVISION_THRESHOLD = 60;
2113
2536
  const showDivisions = state.fov > DIVISION_THRESHOLD;
2114
2537
  if (constellationLines) {
2115
- constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2538
+ constellationLines.visible = linesFader.eased > 0.01;
2539
+ if (constellationLines.visible && constellationLines.material) {
2540
+ const mat = constellationLines.material;
2541
+ if (mat.uniforms?.color) {
2542
+ mat.uniforms.color.value.setHex(11193599);
2543
+ mat.opacity = linesFader.eased;
2544
+ }
2545
+ }
2116
2546
  }
2117
2547
  if (boundaryLines) {
2118
2548
  boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
@@ -2133,8 +2563,7 @@ function createEngine({
2133
2563
  const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2134
2564
  const showChapterLabels = currentConfig?.showChapterLabels === true;
2135
2565
  const showGroupLabels = currentConfig?.showGroupLabels === true;
2136
- const showBooks = state.fov < 120;
2137
- const showChapters = state.fov < 70;
2566
+ const showChapters = state.fov < 45;
2138
2567
  for (const item of dynamicLabels) {
2139
2568
  const uniforms = item.obj.material.uniforms;
2140
2569
  const level = item.node.level;
@@ -2155,11 +2584,6 @@ function createEngine({
2155
2584
  item.obj.visible = uniforms.uAlpha.value > 0.01;
2156
2585
  continue;
2157
2586
  }
2158
- if (level === 2 && !showBooks && item.node.id !== state.draggedNodeId) {
2159
- uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2160
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2161
- continue;
2162
- }
2163
2587
  if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2164
2588
  uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2165
2589
  item.obj.visible = uniforms.uAlpha.value > 0.01;
@@ -2192,8 +2616,8 @@ function createEngine({
2192
2616
  const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2193
2617
  if (l.level === 1) {
2194
2618
  let rot = 0;
2195
- const blend = globalUniforms.uBlend.value;
2196
- if (blend > 0.5) {
2619
+ const isWideAngle = currentProjection.id !== "perspective";
2620
+ if (isWideAngle) {
2197
2621
  const dx = l.sX - screenW / 2;
2198
2622
  const dy = l.sY - screenH / 2;
2199
2623
  rot = Math.atan2(-dy, -dx) - Math.PI / 2;
@@ -2201,7 +2625,7 @@ function createEngine({
2201
2625
  l.uniforms.uAngle.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2202
2626
  }
2203
2627
  if (l.level === 2) {
2204
- if (showBooks || isSpecial) {
2628
+ {
2205
2629
  target2 = 1;
2206
2630
  occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2207
2631
  }
@@ -2223,6 +2647,17 @@ function createEngine({
2223
2647
  }
2224
2648
  }
2225
2649
  }
2650
+ if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2651
+ const node = l.item.node;
2652
+ if (node.level === 3) {
2653
+ target2 = 0;
2654
+ } else if (node.level === 2 || node.level === 2.5) {
2655
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2656
+ if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2657
+ target2 = 0;
2658
+ }
2659
+ }
2660
+ }
2226
2661
  l.uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2227
2662
  l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2228
2663
  }
@@ -2237,6 +2672,8 @@ function createEngine({
2237
2672
  window.removeEventListener("mousemove", onMouseMove);
2238
2673
  window.removeEventListener("mouseup", onMouseUp);
2239
2674
  el.removeEventListener("wheel", onWheel);
2675
+ el.removeEventListener("mouseleave", onWindowBlur);
2676
+ window.removeEventListener("blur", onWindowBlur);
2240
2677
  }
2241
2678
  function dispose() {
2242
2679
  stop();
@@ -2257,7 +2694,30 @@ function createEngine({
2257
2694
  function setOrderRevealEnabled(enabled) {
2258
2695
  orderRevealEnabled = enabled;
2259
2696
  }
2260
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled };
2697
+ function flyTo(nodeId, targetFov) {
2698
+ const node = nodeById.get(nodeId);
2699
+ if (!node) return;
2700
+ const pos = getPosition(node).normalize();
2701
+ flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2702
+ flyToTargetLon = Math.atan2(pos.x, -pos.z);
2703
+ flyToTargetFov = targetFov ?? ENGINE_CONFIG.minFov;
2704
+ flyToActive = true;
2705
+ state.velocityX = 0;
2706
+ state.velocityY = 0;
2707
+ }
2708
+ function setHierarchyFilter(filter) {
2709
+ currentFilter = filter;
2710
+ if (filter) {
2711
+ filterTestamentIndex = filter.testament && testamentToIndex.has(filter.testament) ? testamentToIndex.get(filter.testament) : -1;
2712
+ filterDivisionIndex = filter.division && divisionToIndex.has(filter.division) ? divisionToIndex.get(filter.division) : -1;
2713
+ filterBookIndex = filter.bookKey && bookIdToIndex.has(`B:${filter.bookKey}`) ? bookIdToIndex.get(`B:${filter.bookKey}`) : -1;
2714
+ } else {
2715
+ filterTestamentIndex = -1;
2716
+ filterDivisionIndex = -1;
2717
+ filterBookIndex = -1;
2718
+ }
2719
+ }
2720
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
2261
2721
  }
2262
2722
  var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
2263
2723
  var init_createEngine = __esm({
@@ -2265,16 +2725,18 @@ var init_createEngine = __esm({
2265
2725
  init_layout();
2266
2726
  init_materials();
2267
2727
  init_ConstellationArtworkLayer();
2728
+ init_projections();
2729
+ init_fader();
2268
2730
  ENGINE_CONFIG = {
2269
2731
  minFov: 10,
2270
- maxFov: 165,
2271
- defaultFov: 80,
2732
+ maxFov: 135,
2733
+ defaultFov: 50,
2272
2734
  dragSpeed: 125e-5,
2273
2735
  inertiaDamping: 0.92,
2274
- blendStart: 60,
2275
- blendEnd: 165,
2276
- zenithStartFov: 110,
2277
- zenithStrength: 0.02,
2736
+ blendStart: 35,
2737
+ blendEnd: 83,
2738
+ zenithStartFov: 75,
2739
+ zenithStrength: 0.15,
2278
2740
  horizonLockStrength: 0.05,
2279
2741
  edgePanThreshold: 0.15,
2280
2742
  edgePanMaxSpeed: 0.02,
@@ -2296,7 +2758,10 @@ var StarMap = react.forwardRef(
2296
2758
  getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2297
2759
  setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2298
2760
  setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2299
- setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled)
2761
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled),
2762
+ setHierarchyFilter: (filter) => engineRef.current?.setHierarchyFilter?.(filter),
2763
+ flyTo: (nodeId, targetFov) => engineRef.current?.flyTo?.(nodeId, targetFov),
2764
+ setProjection: (id) => engineRef.current?.setProjection?.(id)
2300
2765
  }));
2301
2766
  react.useEffect(() => {
2302
2767
  let disposed = false;
@@ -31573,6 +32038,9 @@ function generateArrangement(bible, options = {}) {
31573
32038
  return arrangement;
31574
32039
  }
31575
32040
 
32041
+ // src/index.ts
32042
+ init_projections();
32043
+
31576
32044
  exports.StarMap = StarMap;
31577
32045
  exports.bibleToSceneModel = bibleToSceneModel;
31578
32046
  exports.defaultGenerateOptions = defaultGenerateOptions;