@project-skymap/library 0.5.0 → 0.7.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,167 @@ 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;
750
+ blendEnd;
751
+ /** Current blend factor, updated via setFov() */
752
+ blend = 0;
753
+ constructor(blendStart = 40, blendEnd = 100) {
754
+ this.blendStart = blendStart;
755
+ this.blendEnd = blendEnd;
756
+ }
757
+ /** Call this each frame / when FOV changes so forward/inverse stay in sync */
758
+ setFov(fovDeg) {
759
+ if (fovDeg <= this.blendStart) {
760
+ this.blend = 0;
761
+ return;
762
+ }
763
+ if (fovDeg >= this.blendEnd) {
764
+ this.blend = 1;
765
+ return;
766
+ }
767
+ const t = (fovDeg - this.blendStart) / (this.blendEnd - this.blendStart);
768
+ this.blend = t * t * (3 - 2 * t);
769
+ }
770
+ getBlend() {
771
+ return this.blend;
772
+ }
773
+ forward(dir) {
774
+ if (this.blend > 0.5 && dir.z > 0.4) return null;
775
+ if (this.blend < 0.1 && dir.z > -0.1) return null;
776
+ const kLinear = 1 / Math.max(0.01, -dir.z);
777
+ const kStereo = 2 / (1 - dir.z);
778
+ const k = kLinear * (1 - this.blend) + kStereo * this.blend;
779
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
780
+ }
781
+ inverse(uvX, uvY, fovRad) {
782
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
783
+ const halfHeightLin = Math.tan(fovRad / 2);
784
+ const thetaLin = Math.atan(r * halfHeightLin);
785
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
786
+ const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
787
+ const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
788
+ const phi = Math.atan2(uvY, uvX);
789
+ const sinT = Math.sin(theta);
790
+ return {
791
+ x: sinT * Math.cos(phi),
792
+ y: sinT * Math.sin(phi),
793
+ z: -Math.cos(theta)
794
+ };
795
+ }
796
+ getScale(fovRad) {
797
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
798
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
799
+ return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
800
+ }
801
+ isClipped(dirZ) {
802
+ if (this.blend > 0.5) return dirZ > 0.4;
803
+ if (this.blend < 0.1) return dirZ > -0.1;
804
+ return false;
805
+ }
806
+ };
807
+ exports.PROJECTIONS = {
808
+ perspective: () => new PerspectiveProjection(),
809
+ stereographic: () => new StereographicProjection()
810
+ };
811
+ }
812
+ });
813
+
814
+ // src/engine/fader.ts
815
+ var Fader;
816
+ var init_fader = __esm({
817
+ "src/engine/fader.ts"() {
818
+ Fader = class {
819
+ target = false;
820
+ value = 0;
821
+ duration;
822
+ constructor(duration = 0.3) {
823
+ this.duration = duration;
824
+ }
825
+ update(dt) {
826
+ const goal = this.target ? 1 : 0;
827
+ if (this.value === goal) return;
828
+ const speed = 1 / this.duration;
829
+ const step = speed * dt;
830
+ const diff = goal - this.value;
831
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
832
+ }
833
+ /** Smoothstep-eased value for perceptually smooth transitions */
834
+ get eased() {
835
+ const v = this.value;
836
+ return v * v * (3 - 2 * v);
837
+ }
838
+ };
839
+ }
840
+ });
841
+
660
842
  // src/engine/createEngine.ts
661
843
  var createEngine_exports = {};
662
844
  __export(createEngine_exports, {
@@ -674,9 +856,21 @@ function createEngine({
674
856
  let orderRevealEnabled = true;
675
857
  let activeBookIndex = -1;
676
858
  let orderRevealStrength = 0;
859
+ let flyToActive = false;
860
+ let flyToTargetLon = 0;
861
+ let flyToTargetLat = 0;
862
+ let flyToTargetFov = ENGINE_CONFIG.minFov;
863
+ const FLY_TO_SPEED = 0.04;
864
+ let currentFilter = null;
865
+ let filterStrength = 0;
866
+ let filterTestamentIndex = -1;
867
+ let filterDivisionIndex = -1;
868
+ let filterBookIndex = -1;
677
869
  const hoverCooldowns = /* @__PURE__ */ new Map();
678
870
  const COOLDOWN_MS = 2e3;
679
871
  const bookIdToIndex = /* @__PURE__ */ new Map();
872
+ const testamentToIndex = /* @__PURE__ */ new Map();
873
+ const divisionToIndex = /* @__PURE__ */ new Map();
680
874
  const renderer = new THREE5__namespace.WebGLRenderer({ antialias: true, alpha: false });
681
875
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
682
876
  renderer.setSize(container.clientWidth, container.clientHeight);
@@ -715,10 +909,21 @@ function createEngine({
715
909
  draggedStarIndex: -1,
716
910
  draggedDist: 2e3,
717
911
  draggedGroup: null,
718
- tempArrangement: {}
912
+ tempArrangement: {},
913
+ // Touch state
914
+ touchCount: 0,
915
+ touchStartTime: 0,
916
+ touchStartX: 0,
917
+ touchStartY: 0,
918
+ touchMoved: false,
919
+ pinchStartDistance: 0,
920
+ pinchStartFov: ENGINE_CONFIG.defaultFov,
921
+ pinchCenterX: 0,
922
+ pinchCenterY: 0
719
923
  };
720
924
  const mouseNDC = new THREE5__namespace.Vector2();
721
925
  let isMouseInWindow = false;
926
+ let isTouchDevice = false;
722
927
  let edgeHoverStart = 0;
723
928
  let handlers = { onSelect, onHover, onArrangementChange, onFovChange };
724
929
  let currentConfig;
@@ -726,68 +931,53 @@ function createEngine({
726
931
  function mix(a, b, t) {
727
932
  return a * (1 - t) + b * t;
728
933
  }
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);
934
+ let currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
935
+ function syncProjectionState() {
936
+ if (currentProjection instanceof BlendedProjection) {
937
+ currentProjection.setFov(state.fov);
938
+ globalUniforms.uBlend.value = currentProjection.getBlend();
939
+ }
940
+ globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
734
941
  }
735
942
  function updateUniforms() {
736
- const blend = getBlendFactor(state.fov);
737
- globalUniforms.uBlend.value = blend;
943
+ syncProjectionState();
738
944
  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;
945
+ let scale = currentProjection.getScale(fovRad);
946
+ const aspect = camera.aspect;
947
+ if (currentConfig?.fitProjection) {
948
+ if (aspect > 1) {
949
+ scale /= aspect;
950
+ }
951
+ }
952
+ globalUniforms.uScale.value = scale;
953
+ globalUniforms.uAspect.value = aspect;
743
954
  camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
744
955
  camera.updateProjectionMatrix();
745
956
  }
746
957
  function getMouseViewVector(fovDeg, aspectRatio) {
747
- const blend = getBlendFactor(fovDeg);
958
+ syncProjectionState();
748
959
  const fovRad = fovDeg * Math.PI / 180;
749
960
  const uvX = mouseNDC.x * aspectRatio;
750
961
  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();
962
+ const v = currentProjection.inverse(uvX, uvY, fovRad);
963
+ return new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
761
964
  }
762
965
  function getMouseWorldVector(pixelX, pixelY, width, height) {
763
966
  const aspect = width / height;
764
967
  const ndcX = pixelX / width * 2 - 1;
765
968
  const ndcY = -(pixelY / height) * 2 + 1;
766
- const blend = getBlendFactor(state.fov);
969
+ syncProjectionState();
767
970
  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();
971
+ const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
972
+ const vView = new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
780
973
  return vView.applyQuaternion(camera.quaternion);
781
974
  }
782
975
  function smartProjectJS(worldPos) {
783
976
  const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
784
977
  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 };
978
+ const result = currentProjection.forward(dir);
979
+ if (!result) return { x: 0, y: 0, z: dir.z };
980
+ return result;
791
981
  }
792
982
  const groundGroup = new THREE5__namespace.Group();
793
983
  scene.add(groundGroup);
@@ -797,10 +987,8 @@ function createEngine({
797
987
  const geometry = new THREE5__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
798
988
  const material = createSmartMaterial({
799
989
  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
990
+ color: { value: new THREE5__namespace.Color(65794) },
991
+ fogColor: { value: new THREE5__namespace.Color(663098) }
804
992
  },
805
993
  vertexShaderBody: `
806
994
  varying vec3 vPos;
@@ -826,24 +1014,30 @@ function createEngine({
826
1014
  // Procedural Horizon (Mountains)
827
1015
  float angle = atan(vPos.z, vPos.x);
828
1016
 
829
- // Simple FBM-like terrain
1017
+ // FBM-like terrain with increased amplitude
830
1018
  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
-
1019
+ h += sin(angle * 6.0) * 35.0;
1020
+ h += sin(angle * 13.0 + 1.0) * 18.0;
1021
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1022
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1023
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1024
+
1025
+ float terrainHeight = h + 12.0;
1026
+
839
1027
  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
-
1028
+
1029
+ // Atmospheric rim glow just below terrain peaks
1030
+ float rimDist = terrainHeight - vPos.y;
1031
+ float rim = exp(-rimDist * 0.15) * 0.4;
1032
+ vec3 rimColor = fogColor * 1.5;
1033
+
1034
+ // Atmospheric haze \u2014 stronger near horizon
1035
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1036
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1037
+
1038
+ // Add rim glow near terrain peaks
1039
+ finalCol += rimColor * rim;
1040
+
847
1041
  gl_FragColor = vec4(finalCol, 1.0);
848
1042
  }
849
1043
  `,
@@ -881,19 +1075,25 @@ function createEngine({
881
1075
 
882
1076
  // Altitude angle (Y is up)
883
1077
  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
-
1078
+
1079
+ // 1. Base gradient from Horizon to Zenith (wider range)
1080
+ float t = smoothstep(-0.15, 0.7, h);
1081
+
889
1082
  // Non-linear mix for realistic sky falloff
890
- // Zenith darkness adjustment
891
1083
  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
1084
+
1085
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
1086
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1087
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
1088
+
1089
+ // 3. Primary horizon glow band (wider than before)
1090
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
895
1091
  skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
896
1092
 
1093
+ // 4. Warm secondary glow (light pollution / sodium scatter)
1094
+ float warmGlow = exp(-8.0 * abs(h));
1095
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1096
+
897
1097
  gl_FragColor = vec4(skyColor, 1.0);
898
1098
  }
899
1099
  `,
@@ -931,7 +1131,24 @@ function createEngine({
931
1131
  positions.push(x, y, z);
932
1132
  const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
933
1133
  sizes.push(size);
934
- colors.push(1, 1, 1);
1134
+ const temp = Math.random();
1135
+ let cr, cg, cb;
1136
+ if (temp < 0.15) {
1137
+ cr = 0.7 + temp * 2;
1138
+ cg = 0.8 + temp;
1139
+ cb = 1;
1140
+ } else if (temp < 0.6) {
1141
+ const t = (temp - 0.15) / 0.45;
1142
+ cr = 1;
1143
+ cg = 1 - t * 0.1;
1144
+ cb = 1 - t * 0.3;
1145
+ } else {
1146
+ const t = (temp - 0.6) / 0.4;
1147
+ cr = 1;
1148
+ cg = 0.85 - t * 0.35;
1149
+ cb = 0.7 - t * 0.35;
1150
+ }
1151
+ colors.push(cr, cg, cb);
935
1152
  }
936
1153
  geometry.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(positions, 3));
937
1154
  geometry.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(sizes, 1));
@@ -939,51 +1156,60 @@ function createEngine({
939
1156
  const material = createSmartMaterial({
940
1157
  uniforms: {
941
1158
  pixelRatio: { value: renderer.getPixelRatio() },
942
- uScale: globalUniforms.uScale
1159
+ uScale: globalUniforms.uScale,
1160
+ uTime: globalUniforms.uTime
943
1161
  },
944
1162
  vertexShaderBody: `
945
- attribute float size;
946
- attribute vec3 color;
947
- varying vec3 vColor;
948
- uniform float pixelRatio;
949
-
1163
+ attribute float size;
1164
+ attribute vec3 color;
1165
+ varying vec3 vColor;
1166
+ uniform float pixelRatio;
1167
+
950
1168
  uniform float uAtmExtinction;
1169
+ uniform float uAtmTwinkle;
1170
+ uniform float uTime;
951
1171
 
952
- void main() {
1172
+ void main() {
953
1173
  vec3 nPos = normalize(position);
954
1174
  float altitude = nPos.y;
955
-
956
- // Simple Extinction & Horizon Fade
1175
+
1176
+ // Extinction & Horizon Fade
957
1177
  float horizonFade = smoothstep(-0.1, 0.1, altitude);
958
1178
  float airmass = 1.0 / (max(0.05, altitude + 0.05));
959
1179
  float extinction = exp(-uAtmExtinction * 0.15 * airmass);
960
1180
 
961
- // Boost intensity significantly (3.0x)
962
- vColor = color * 3.0 * extinction * horizonFade;
1181
+ // Scintillation (twinkling) \u2014 stronger near horizon
1182
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1183
+ float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1184
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
963
1185
 
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;
1186
+ vColor = color * 3.0 * extinction * horizonFade * scintillation;
1187
+
1188
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1189
+ gl_Position = smartProject(mvPosition);
1190
+ vScreenPos = gl_Position.xy / gl_Position.w;
1191
+
1192
+ float zoomScale = pow(uScale, 0.5);
1193
+ float perceptualSize = pow(size, 0.55);
1194
+ gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
973
1195
  }
974
1196
  `,
975
1197
  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);
1198
+ varying vec3 vColor;
1199
+ void main() {
1200
+ vec2 coord = gl_PointCoord - vec2(0.5);
1201
+ float d = length(coord) * 2.0;
1202
+ if (d > 1.0) discard;
1203
+ float alphaMask = getMaskAlpha();
1204
+ if (alphaMask < 0.01) discard;
1205
+
1206
+ // Stellarium-style: sharp core + soft glow
1207
+ float core = smoothstep(0.8, 0.4, d);
1208
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1209
+ float k = core + glow;
1210
+
1211
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.5);
1212
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
987
1213
  }
988
1214
  `,
989
1215
  transparent: true,
@@ -1056,6 +1282,9 @@ function createEngine({
1056
1282
  let constellationLines = null;
1057
1283
  let boundaryLines = null;
1058
1284
  let starPoints = null;
1285
+ const linesFader = new Fader(0.4);
1286
+ const artFader = new Fader(0.5);
1287
+ let lastTickTime = 0;
1059
1288
  function clearRoot() {
1060
1289
  for (const child of [...root.children]) {
1061
1290
  root.remove(child);
@@ -1126,6 +1355,8 @@ function createEngine({
1126
1355
  function buildFromModel(model, cfg) {
1127
1356
  clearRoot();
1128
1357
  bookIdToIndex.clear();
1358
+ testamentToIndex.clear();
1359
+ divisionToIndex.clear();
1129
1360
  scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5__namespace.Color(cfg.background) : new THREE5__namespace.Color(0);
1130
1361
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1131
1362
  const laidOut = computeLayoutPositions(model, layoutCfg);
@@ -1159,6 +1390,8 @@ function createEngine({
1159
1390
  const starPhases = [];
1160
1391
  const starBookIndices = [];
1161
1392
  const starChapterIndices = [];
1393
+ const starTestamentIndices = [];
1394
+ const starDivisionIndices = [];
1162
1395
  const SPECTRAL_COLORS = [
1163
1396
  new THREE5__namespace.Color(14544639),
1164
1397
  // O - Blueish White
@@ -1216,12 +1449,32 @@ function createEngine({
1216
1449
  let cIdx = 0;
1217
1450
  if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1218
1451
  starChapterIndices.push(cIdx);
1452
+ let tIdx = -1;
1453
+ if (n.meta?.testament) {
1454
+ const tName = n.meta.testament;
1455
+ if (!testamentToIndex.has(tName)) {
1456
+ testamentToIndex.set(tName, testamentToIndex.size + 1);
1457
+ }
1458
+ tIdx = testamentToIndex.get(tName);
1459
+ }
1460
+ starTestamentIndices.push(tIdx);
1461
+ let dIdx = -1;
1462
+ if (n.meta?.division) {
1463
+ const dName = n.meta.division;
1464
+ if (!divisionToIndex.has(dName)) {
1465
+ divisionToIndex.set(dName, divisionToIndex.size + 1);
1466
+ }
1467
+ dIdx = divisionToIndex.get(dName);
1468
+ }
1469
+ starDivisionIndices.push(dIdx);
1219
1470
  }
1220
1471
  if (n.level === 1 || n.level === 2 || n.level === 3) {
1221
1472
  let color = "#ffffff";
1222
1473
  if (n.level === 1) color = "#38bdf8";
1223
- else if (n.level === 2) color = "#cbd5e1";
1224
- else if (n.level === 3) color = "#94a3b8";
1474
+ else if (n.level === 2) {
1475
+ const bookKey = n.meta?.bookKey;
1476
+ color = bookKey && cfg.labelColors?.[bookKey] || "#cbd5e1";
1477
+ } else if (n.level === 3) color = "#94a3b8";
1225
1478
  let labelText = n.label;
1226
1479
  if (n.level === 3 && n.meta?.chapter) {
1227
1480
  labelText = String(n.meta.chapter);
@@ -1302,6 +1555,8 @@ function createEngine({
1302
1555
  starGeo.setAttribute("phase", new THREE5__namespace.Float32BufferAttribute(starPhases, 1));
1303
1556
  starGeo.setAttribute("bookIndex", new THREE5__namespace.Float32BufferAttribute(starBookIndices, 1));
1304
1557
  starGeo.setAttribute("chapterIndex", new THREE5__namespace.Float32BufferAttribute(starChapterIndices, 1));
1558
+ starGeo.setAttribute("testamentIndex", new THREE5__namespace.Float32BufferAttribute(starTestamentIndices, 1));
1559
+ starGeo.setAttribute("divisionIndex", new THREE5__namespace.Float32BufferAttribute(starDivisionIndices, 1));
1305
1560
  const starMat = createSmartMaterial({
1306
1561
  uniforms: {
1307
1562
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1314,7 +1569,12 @@ function createEngine({
1314
1569
  ORDER_REVEAL_CONFIG.pulseDuration,
1315
1570
  ORDER_REVEAL_CONFIG.delayPerChapter,
1316
1571
  ORDER_REVEAL_CONFIG.pulseAmplitude
1317
- ) }
1572
+ ) },
1573
+ uFilterTestamentIndex: { value: -1 },
1574
+ uFilterDivisionIndex: { value: -1 },
1575
+ uFilterBookIndex: { value: -1 },
1576
+ uFilterStrength: { value: 0 },
1577
+ uFilterDimFactor: { value: 0.08 }
1318
1578
  },
1319
1579
  vertexShaderBody: `
1320
1580
  attribute float size;
@@ -1322,10 +1582,12 @@ function createEngine({
1322
1582
  attribute float phase;
1323
1583
  attribute float bookIndex;
1324
1584
  attribute float chapterIndex;
1585
+ attribute float testamentIndex;
1586
+ attribute float divisionIndex;
1587
+
1588
+ varying vec3 vColor;
1589
+ uniform float pixelRatio;
1325
1590
 
1326
- varying vec3 vColor;
1327
- uniform float pixelRatio;
1328
-
1329
1591
  uniform float uTime;
1330
1592
  uniform float uAtmExtinction;
1331
1593
  uniform float uAtmTwinkle;
@@ -1335,6 +1597,12 @@ function createEngine({
1335
1597
  uniform float uGlobalDimFactor;
1336
1598
  uniform vec3 uPulseParams;
1337
1599
 
1600
+ uniform float uFilterTestamentIndex;
1601
+ uniform float uFilterDivisionIndex;
1602
+ uniform float uFilterBookIndex;
1603
+ uniform float uFilterStrength;
1604
+ uniform float uFilterDimFactor;
1605
+
1338
1606
  void main() {
1339
1607
  vec3 nPos = normalize(position);
1340
1608
 
@@ -1369,8 +1637,21 @@ function createEngine({
1369
1637
 
1370
1638
  float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1371
1639
 
1640
+ // --- Hierarchy Filter ---
1641
+ float filtered = 0.0;
1642
+ if (uFilterTestamentIndex >= 0.0) {
1643
+ filtered = 1.0 - step(0.5, 1.0 - abs(testamentIndex - uFilterTestamentIndex));
1644
+ }
1645
+ if (uFilterDivisionIndex >= 0.0 && filtered < 0.5) {
1646
+ filtered = 1.0 - step(0.5, 1.0 - abs(divisionIndex - uFilterDivisionIndex));
1647
+ }
1648
+ if (uFilterBookIndex >= 0.0 && filtered < 0.5) {
1649
+ filtered = 1.0 - step(0.5, 1.0 - abs(bookIndex - uFilterBookIndex));
1650
+ }
1651
+ float filterDim = mix(1.0, uFilterDimFactor, uFilterStrength * filtered);
1652
+
1372
1653
  vec3 baseColor = color * extinction * horizonFade * scintillation;
1373
- vColor = baseColor * dimFactor;
1654
+ vColor = baseColor * dimFactor * filterDim;
1374
1655
  vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1375
1656
 
1376
1657
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
@@ -1378,7 +1659,8 @@ function createEngine({
1378
1659
  vScreenPos = gl_Position.xy / gl_Position.w;
1379
1660
 
1380
1661
  float sizeBoost = 1.0 + activePulse * 0.8;
1381
- gl_PointSize = (size * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade;
1662
+ float perceptualSize = pow(size, 0.55);
1663
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
1382
1664
  }
1383
1665
  `,
1384
1666
  fragmentShader: `
@@ -1391,15 +1673,14 @@ function createEngine({
1391
1673
  float alphaMask = getMaskAlpha();
1392
1674
  if (alphaMask < 0.01) discard;
1393
1675
 
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);
1676
+ // Stellarium-style dual-layer: sharp core + soft glow
1677
+ float core = smoothstep(0.8, 0.4, d);
1678
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1679
+ float k = core + glow;
1680
+
1681
+ // White-hot core blending into coloured halo
1682
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1683
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
1403
1684
  }
1404
1685
  `,
1405
1686
  transparent: true,
@@ -1433,17 +1714,89 @@ function createEngine({
1433
1714
  }
1434
1715
  }
1435
1716
  if (linePoints.length > 0) {
1717
+ const quadPositions = [];
1718
+ const quadUvs = [];
1719
+ const quadIndices = [];
1720
+ const lineWidth = 8;
1721
+ for (let i = 0; i < linePoints.length; i += 6) {
1722
+ const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1723
+ const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
1724
+ const dx = bx - ax, dy = by - ay, dz = bz - az;
1725
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1726
+ if (len < 1e-3) continue;
1727
+ let px = dy * 0 - dz * 1, py = dz * 0 - dx * 0, pz = dx * 1 - dy * 0;
1728
+ const pLen = Math.sqrt(px * px + py * py + pz * pz);
1729
+ if (pLen < 1e-3) {
1730
+ px = 1;
1731
+ py = 0;
1732
+ pz = 0;
1733
+ } else {
1734
+ px /= pLen;
1735
+ py /= pLen;
1736
+ pz /= pLen;
1737
+ }
1738
+ const hw = lineWidth;
1739
+ const baseIdx = quadPositions.length / 3;
1740
+ quadPositions.push(ax - px * hw, ay - py * hw, az - pz * hw);
1741
+ quadUvs.push(0, -1);
1742
+ quadPositions.push(ax + px * hw, ay + py * hw, az + pz * hw);
1743
+ quadUvs.push(0, 1);
1744
+ quadPositions.push(bx - px * hw, by - py * hw, bz - pz * hw);
1745
+ quadUvs.push(1, -1);
1746
+ quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1747
+ quadUvs.push(1, 1);
1748
+ quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1749
+ }
1436
1750
  const lineGeo = new THREE5__namespace.BufferGeometry();
1437
- lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(linePoints, 3));
1751
+ lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(quadPositions, 3));
1752
+ lineGeo.setAttribute("lineUv", new THREE5__namespace.Float32BufferAttribute(quadUvs, 2));
1753
+ lineGeo.setIndex(quadIndices);
1438
1754
  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); }`,
1755
+ uniforms: {
1756
+ color: { value: new THREE5__namespace.Color(11193599) },
1757
+ uLineWidth: { value: 1.5 },
1758
+ uGlowIntensity: { value: 0.3 }
1759
+ },
1760
+ vertexShaderBody: `
1761
+ attribute vec2 lineUv;
1762
+ varying vec2 vLineUv;
1763
+ void main() {
1764
+ vLineUv = lineUv;
1765
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1766
+ gl_Position = smartProject(mvPosition);
1767
+ vScreenPos = gl_Position.xy / gl_Position.w;
1768
+ }
1769
+ `,
1770
+ fragmentShader: `
1771
+ uniform vec3 color;
1772
+ uniform float uLineWidth;
1773
+ uniform float uGlowIntensity;
1774
+ varying vec2 vLineUv;
1775
+ void main() {
1776
+ float alphaMask = getMaskAlpha();
1777
+ if (alphaMask < 0.01) discard;
1778
+
1779
+ float dist = abs(vLineUv.y);
1780
+
1781
+ // Anti-aliased core line
1782
+ float hw = uLineWidth * 0.05;
1783
+ float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1784
+
1785
+ // Soft glow extending outward
1786
+ float glow = (1.0 - dist) * uGlowIntensity;
1787
+
1788
+ float alpha = max(glow, base);
1789
+ if (alpha < 0.005) discard;
1790
+
1791
+ gl_FragColor = vec4(color, alpha * alphaMask);
1792
+ }
1793
+ `,
1442
1794
  transparent: true,
1443
1795
  depthWrite: false,
1444
- blending: THREE5__namespace.AdditiveBlending
1796
+ blending: THREE5__namespace.AdditiveBlending,
1797
+ side: THREE5__namespace.DoubleSide
1445
1798
  });
1446
- constellationLines = new THREE5__namespace.LineSegments(lineGeo, lineMat);
1799
+ constellationLines = new THREE5__namespace.Mesh(lineGeo, lineMat);
1447
1800
  constellationLines.frustumCulled = false;
1448
1801
  root.add(constellationLines);
1449
1802
  }
@@ -1615,8 +1968,19 @@ function createEngine({
1615
1968
  let lastAppliedLon = void 0;
1616
1969
  let lastAppliedLat = void 0;
1617
1970
  let lastBackdropCount = void 0;
1971
+ function setProjection(id) {
1972
+ if (id === "blended") {
1973
+ currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
1974
+ } else {
1975
+ const factory = exports.PROJECTIONS[id];
1976
+ if (!factory) return;
1977
+ currentProjection = factory();
1978
+ }
1979
+ updateUniforms();
1980
+ }
1618
1981
  function setConfig(cfg) {
1619
1982
  currentConfig = cfg;
1983
+ if (cfg.projection) setProjection(cfg.projection);
1620
1984
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1621
1985
  state.lon = cfg.camera.lon;
1622
1986
  state.targetLon = cfg.camera.lon;
@@ -1706,6 +2070,15 @@ function createEngine({
1706
2070
  Object.assign(arr, state.tempArrangement);
1707
2071
  return arr;
1708
2072
  }
2073
+ function isNodeFiltered(node) {
2074
+ if (!currentFilter) return false;
2075
+ const meta = node.meta;
2076
+ if (!meta) return false;
2077
+ if (currentFilter.testament && meta.testament !== currentFilter.testament) return true;
2078
+ if (currentFilter.division && meta.division !== currentFilter.division) return true;
2079
+ if (currentFilter.bookKey && meta.bookKey !== currentFilter.bookKey) return true;
2080
+ return false;
2081
+ }
1709
2082
  function pick(ev) {
1710
2083
  const rect = renderer.domElement.getBoundingClientRect();
1711
2084
  const mX = ev.clientX - rect.left;
@@ -1717,13 +2090,14 @@ function createEngine({
1717
2090
  const w = rect.width;
1718
2091
  const h = rect.height;
1719
2092
  let closestLabel = null;
1720
- let minLabelDist = 40;
2093
+ const LABEL_THRESHOLD = isTouchDevice ? 48 : 40;
2094
+ let minLabelDist = LABEL_THRESHOLD;
1721
2095
  for (const item of dynamicLabels) {
1722
2096
  if (!item.obj.visible) continue;
2097
+ if (isNodeFiltered(item.node)) continue;
1723
2098
  const pWorld = item.obj.position;
1724
2099
  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;
2100
+ if (currentProjection.isClipped(pProj.z)) continue;
1727
2101
  const xNDC = pProj.x * uScale / uAspect;
1728
2102
  const yNDC = pProj.y * uScale;
1729
2103
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1745,8 +2119,7 @@ function createEngine({
1745
2119
  if (!item.mesh.visible) continue;
1746
2120
  const pWorld = item.mesh.position;
1747
2121
  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;
2122
+ if (currentProjection.isClipped(pProj.z)) continue;
1750
2123
  const uniforms = item.material.uniforms;
1751
2124
  if (!uniforms || !uniforms.uSize) continue;
1752
2125
  const uSize = uniforms.uSize.value;
@@ -1795,12 +2168,16 @@ function createEngine({
1795
2168
  const id = starIndexToId[pointHit.index];
1796
2169
  if (id) {
1797
2170
  const node = nodeById.get(id);
1798
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2171
+ if (node && !isNodeFiltered(node)) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
1799
2172
  }
1800
2173
  }
1801
2174
  }
1802
2175
  return void 0;
1803
2176
  }
2177
+ function onWindowBlur() {
2178
+ isMouseInWindow = false;
2179
+ edgeHoverStart = 0;
2180
+ }
1804
2181
  function onMouseDown(e) {
1805
2182
  state.lastMouseX = e.clientX;
1806
2183
  state.lastMouseY = e.clientY;
@@ -1838,6 +2215,7 @@ function createEngine({
1838
2215
  }
1839
2216
  return;
1840
2217
  }
2218
+ flyToActive = false;
1841
2219
  state.dragMode = "camera";
1842
2220
  state.isDragging = true;
1843
2221
  state.velocityX = 0;
@@ -1896,11 +2274,13 @@ function createEngine({
1896
2274
  state.lastMouseX = e.clientX;
1897
2275
  state.lastMouseY = e.clientY;
1898
2276
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2277
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2278
+ const latFactor = 1 - rotLock * rotLock;
1899
2279
  state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1900
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2280
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1901
2281
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1902
2282
  state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1903
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2283
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1904
2284
  state.lon = state.targetLon;
1905
2285
  state.lat = state.targetLat;
1906
2286
  } else {
@@ -1934,6 +2314,9 @@ function createEngine({
1934
2314
  }
1935
2315
  }
1936
2316
  function onMouseUp(e) {
2317
+ const dx = e.clientX - state.lastMouseX;
2318
+ const dy = e.clientY - state.lastMouseY;
2319
+ const movedDist = Math.sqrt(dx * dx + dy * dy);
1937
2320
  if (state.dragMode === "node") {
1938
2321
  const fullArr = getFullArrangement();
1939
2322
  handlers.onArrangementChange?.(fullArr);
@@ -1946,6 +2329,17 @@ function createEngine({
1946
2329
  state.isDragging = false;
1947
2330
  state.dragMode = "none";
1948
2331
  document.body.style.cursor = "default";
2332
+ if (movedDist < 5) {
2333
+ const hit = pick(e);
2334
+ if (hit) {
2335
+ handlers.onSelect?.(hit.node);
2336
+ constellationLayer.setFocused(hit.node.id);
2337
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2338
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2339
+ } else {
2340
+ setFocusedBook(null);
2341
+ }
2342
+ }
1949
2343
  } else {
1950
2344
  const hit = pick(e);
1951
2345
  if (hit) {
@@ -1960,6 +2354,7 @@ function createEngine({
1960
2354
  }
1961
2355
  function onWheel(e) {
1962
2356
  e.preventDefault();
2357
+ flyToActive = false;
1963
2358
  const aspect = container.clientWidth / container.clientHeight;
1964
2359
  renderer.domElement.getBoundingClientRect();
1965
2360
  const vBefore = getMouseViewVector(state.fov, aspect);
@@ -1970,6 +2365,17 @@ function createEngine({
1970
2365
  updateUniforms();
1971
2366
  const vAfter = getMouseViewVector(state.fov, aspect);
1972
2367
  const quaternion = new THREE5__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
2368
+ const dampStartFov = 40;
2369
+ const dampEndFov = 120;
2370
+ let spinAmount = 1;
2371
+ if (state.fov > dampStartFov) {
2372
+ const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2373
+ spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2374
+ }
2375
+ if (spinAmount < 0.999) {
2376
+ const identityQuat = new THREE5__namespace.Quaternion();
2377
+ quaternion.slerp(identityQuat, 1 - spinAmount);
2378
+ }
1973
2379
  const y = Math.sin(state.lat);
1974
2380
  const r = Math.cos(state.lat);
1975
2381
  const x = r * Math.sin(state.lon);
@@ -1998,6 +2404,144 @@ function createEngine({
1998
2404
  state.targetLat = state.lat;
1999
2405
  state.targetLon = state.lon;
2000
2406
  }
2407
+ function getTouchDistance(t1, t2) {
2408
+ const dx = t1.clientX - t2.clientX;
2409
+ const dy = t1.clientY - t2.clientY;
2410
+ return Math.sqrt(dx * dx + dy * dy);
2411
+ }
2412
+ function getTouchCenter(t1, t2) {
2413
+ return {
2414
+ x: (t1.clientX + t2.clientX) / 2,
2415
+ y: (t1.clientY + t2.clientY) / 2
2416
+ };
2417
+ }
2418
+ function onTouchStart(e) {
2419
+ e.preventDefault();
2420
+ isTouchDevice = true;
2421
+ const touches = e.touches;
2422
+ state.touchCount = touches.length;
2423
+ if (touches.length === 1) {
2424
+ const touch = touches[0];
2425
+ state.touchStartTime = performance.now();
2426
+ state.touchStartX = touch.clientX;
2427
+ state.touchStartY = touch.clientY;
2428
+ state.touchMoved = false;
2429
+ state.lastMouseX = touch.clientX;
2430
+ state.lastMouseY = touch.clientY;
2431
+ flyToActive = false;
2432
+ state.dragMode = "camera";
2433
+ state.isDragging = true;
2434
+ state.velocityX = 0;
2435
+ state.velocityY = 0;
2436
+ } else if (touches.length === 2) {
2437
+ const t0 = touches[0];
2438
+ const t1 = touches[1];
2439
+ state.pinchStartDistance = getTouchDistance(t0, t1);
2440
+ state.pinchStartFov = state.fov;
2441
+ const center = getTouchCenter(t0, t1);
2442
+ state.pinchCenterX = center.x;
2443
+ state.pinchCenterY = center.y;
2444
+ state.lastMouseX = center.x;
2445
+ state.lastMouseY = center.y;
2446
+ state.touchMoved = true;
2447
+ }
2448
+ }
2449
+ function onTouchMove(e) {
2450
+ e.preventDefault();
2451
+ const touches = e.touches;
2452
+ if (touches.length === 1 && state.dragMode === "camera") {
2453
+ const touch = touches[0];
2454
+ const deltaX = touch.clientX - state.lastMouseX;
2455
+ const deltaY = touch.clientY - state.lastMouseY;
2456
+ state.lastMouseX = touch.clientX;
2457
+ state.lastMouseY = touch.clientY;
2458
+ const totalDx = touch.clientX - state.touchStartX;
2459
+ const totalDy = touch.clientY - state.touchStartY;
2460
+ if (Math.sqrt(totalDx * totalDx + totalDy * totalDy) > ENGINE_CONFIG.tapMaxDistance) {
2461
+ state.touchMoved = true;
2462
+ }
2463
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2464
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2465
+ const latFactor = 1 - rotLock * rotLock;
2466
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2467
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2468
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2469
+ state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2470
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2471
+ state.lon = state.targetLon;
2472
+ state.lat = state.targetLat;
2473
+ } else if (touches.length === 2) {
2474
+ const t0 = touches[0];
2475
+ const t1 = touches[1];
2476
+ const newDistance = getTouchDistance(t0, t1);
2477
+ const scale = newDistance / state.pinchStartDistance;
2478
+ state.fov = state.pinchStartFov / scale;
2479
+ state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2480
+ handlers.onFovChange?.(state.fov);
2481
+ const center = getTouchCenter(t0, t1);
2482
+ const deltaX = center.x - state.lastMouseX;
2483
+ const deltaY = center.y - state.lastMouseY;
2484
+ state.lastMouseX = center.x;
2485
+ state.lastMouseY = center.y;
2486
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2487
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2488
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2489
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2490
+ state.lon = state.targetLon;
2491
+ state.lat = state.targetLat;
2492
+ }
2493
+ }
2494
+ function onTouchEnd(e) {
2495
+ e.preventDefault();
2496
+ const remainingTouches = e.touches.length;
2497
+ if (remainingTouches === 0) {
2498
+ const duration = performance.now() - state.touchStartTime;
2499
+ const wasTap = !state.touchMoved && duration < ENGINE_CONFIG.tapMaxDuration;
2500
+ if (wasTap) {
2501
+ const rect = renderer.domElement.getBoundingClientRect();
2502
+ const mX = state.touchStartX - rect.left;
2503
+ const mY = state.touchStartY - rect.top;
2504
+ mouseNDC.x = mX / rect.width * 2 - 1;
2505
+ mouseNDC.y = -(mY / rect.height) * 2 + 1;
2506
+ const syntheticEvent = {
2507
+ clientX: state.touchStartX,
2508
+ clientY: state.touchStartY
2509
+ };
2510
+ const hit = pick(syntheticEvent);
2511
+ if (hit) {
2512
+ handlers.onSelect?.(hit.node);
2513
+ constellationLayer.setFocused(hit.node.id);
2514
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2515
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2516
+ } else {
2517
+ setFocusedBook(null);
2518
+ }
2519
+ }
2520
+ state.isDragging = false;
2521
+ state.dragMode = "none";
2522
+ state.touchCount = 0;
2523
+ } else if (remainingTouches === 1) {
2524
+ const touch = e.touches[0];
2525
+ state.lastMouseX = touch.clientX;
2526
+ state.lastMouseY = touch.clientY;
2527
+ state.touchCount = 1;
2528
+ state.dragMode = "camera";
2529
+ state.isDragging = true;
2530
+ state.velocityX = 0;
2531
+ state.velocityY = 0;
2532
+ }
2533
+ }
2534
+ function onTouchCancel(e) {
2535
+ e.preventDefault();
2536
+ state.isDragging = false;
2537
+ state.dragMode = "none";
2538
+ state.touchCount = 0;
2539
+ state.velocityX = 0;
2540
+ state.velocityY = 0;
2541
+ }
2542
+ function onGesturePrevent(e) {
2543
+ e.preventDefault();
2544
+ }
2001
2545
  function resize() {
2002
2546
  const w = container.clientWidth || 1;
2003
2547
  const h = container.clientHeight || 1;
@@ -2018,9 +2562,15 @@ function createEngine({
2018
2562
  el.addEventListener("mouseenter", () => {
2019
2563
  isMouseInWindow = true;
2020
2564
  });
2021
- el.addEventListener("mouseleave", () => {
2022
- isMouseInWindow = false;
2023
- });
2565
+ el.addEventListener("mouseleave", onWindowBlur);
2566
+ window.addEventListener("blur", onWindowBlur);
2567
+ el.addEventListener("touchstart", onTouchStart, { passive: false });
2568
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
2569
+ el.addEventListener("touchend", onTouchEnd, { passive: false });
2570
+ el.addEventListener("touchcancel", onTouchCancel, { passive: false });
2571
+ el.addEventListener("gesturestart", onGesturePrevent, { passive: false });
2572
+ el.addEventListener("gesturechange", onGesturePrevent, { passive: false });
2573
+ el.addEventListener("gestureend", onGesturePrevent, { passive: false });
2024
2574
  raf = requestAnimationFrame(tick);
2025
2575
  }
2026
2576
  function tick() {
@@ -2049,9 +2599,20 @@ function createEngine({
2049
2599
  if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2050
2600
  }
2051
2601
  }
2602
+ const filterTarget = currentFilter ? 1 : 0;
2603
+ filterStrength = mix(filterStrength, filterTarget, 0.1);
2604
+ if (filterStrength > 1e-3 || filterTarget > 0) {
2605
+ if (starPoints && starPoints.material) {
2606
+ const m = starPoints.material;
2607
+ if (m.uniforms.uFilterTestamentIndex) m.uniforms.uFilterTestamentIndex.value = filterTestamentIndex;
2608
+ if (m.uniforms.uFilterDivisionIndex) m.uniforms.uFilterDivisionIndex.value = filterDivisionIndex;
2609
+ if (m.uniforms.uFilterBookIndex) m.uniforms.uFilterBookIndex.value = filterBookIndex;
2610
+ if (m.uniforms.uFilterStrength) m.uniforms.uFilterStrength.value = filterStrength;
2611
+ }
2612
+ }
2052
2613
  let panX = 0;
2053
2614
  let panY = 0;
2054
- if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
2615
+ if (!state.isDragging && isMouseInWindow && !currentConfig?.editable && !isTouchDevice) {
2055
2616
  const t = ENGINE_CONFIG.edgePanThreshold;
2056
2617
  const inZoneX = mouseNDC.x < -1 + t || mouseNDC.x > 1 - t;
2057
2618
  const inZoneY = mouseNDC.y < -1 + t || mouseNDC.y > 1 - t;
@@ -2080,16 +2641,33 @@ function createEngine({
2080
2641
  } else {
2081
2642
  edgeHoverStart = 0;
2082
2643
  }
2644
+ if (flyToActive && !state.isDragging) {
2645
+ state.lon = mix(state.lon, flyToTargetLon, FLY_TO_SPEED);
2646
+ state.lat = mix(state.lat, flyToTargetLat, FLY_TO_SPEED);
2647
+ state.fov = mix(state.fov, flyToTargetFov, FLY_TO_SPEED);
2648
+ state.targetLon = state.lon;
2649
+ state.targetLat = state.lat;
2650
+ state.velocityX = 0;
2651
+ state.velocityY = 0;
2652
+ handlers.onFovChange?.(state.fov);
2653
+ if (Math.abs(state.lon - flyToTargetLon) < 1e-4 && Math.abs(state.lat - flyToTargetLat) < 1e-4 && Math.abs(state.fov - flyToTargetFov) < 0.05) {
2654
+ flyToActive = false;
2655
+ state.lon = flyToTargetLon;
2656
+ state.lat = flyToTargetLat;
2657
+ state.fov = flyToTargetFov;
2658
+ }
2659
+ }
2083
2660
  if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2084
2661
  state.lon += panX;
2085
2662
  state.lat += panY;
2086
2663
  state.targetLon = state.lon;
2087
2664
  state.targetLat = state.lat;
2088
- } else if (!state.isDragging) {
2665
+ } else if (!state.isDragging && !flyToActive) {
2089
2666
  state.lon += state.velocityX;
2090
2667
  state.lat += state.velocityY;
2091
- state.velocityX *= ENGINE_CONFIG.inertiaDamping;
2092
- state.velocityY *= ENGINE_CONFIG.inertiaDamping;
2668
+ const damping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
2669
+ state.velocityX *= damping;
2670
+ state.velocityY *= damping;
2093
2671
  if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2094
2672
  if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
2095
2673
  }
@@ -2106,13 +2684,30 @@ function createEngine({
2106
2684
  camera.updateMatrixWorld();
2107
2685
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
2108
2686
  updateUniforms();
2109
- constellationLayer.update(state.fov, currentConfig?.showConstellationArt ?? false);
2687
+ const nowSec = now / 1e3;
2688
+ const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2689
+ lastTickTime = nowSec;
2690
+ linesFader.target = currentConfig?.showConstellationLines ?? false;
2691
+ linesFader.update(dt);
2692
+ artFader.target = currentConfig?.showConstellationArt ?? false;
2693
+ artFader.update(dt);
2694
+ constellationLayer.update(state.fov, artFader.eased > 0.01);
2695
+ if (artFader.eased < 1) {
2696
+ constellationLayer.setGlobalOpacity?.(artFader.eased);
2697
+ }
2110
2698
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2111
2699
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2112
2700
  const DIVISION_THRESHOLD = 60;
2113
2701
  const showDivisions = state.fov > DIVISION_THRESHOLD;
2114
2702
  if (constellationLines) {
2115
- constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2703
+ constellationLines.visible = linesFader.eased > 0.01;
2704
+ if (constellationLines.visible && constellationLines.material) {
2705
+ const mat = constellationLines.material;
2706
+ if (mat.uniforms?.color) {
2707
+ mat.uniforms.color.value.setHex(11193599);
2708
+ mat.opacity = linesFader.eased;
2709
+ }
2710
+ }
2116
2711
  }
2117
2712
  if (boundaryLines) {
2118
2713
  boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
@@ -2133,8 +2728,7 @@ function createEngine({
2133
2728
  const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2134
2729
  const showChapterLabels = currentConfig?.showChapterLabels === true;
2135
2730
  const showGroupLabels = currentConfig?.showGroupLabels === true;
2136
- const showBooks = state.fov < 120;
2137
- const showChapters = state.fov < 70;
2731
+ const showChapters = state.fov < 45;
2138
2732
  for (const item of dynamicLabels) {
2139
2733
  const uniforms = item.obj.material.uniforms;
2140
2734
  const level = item.node.level;
@@ -2155,11 +2749,6 @@ function createEngine({
2155
2749
  item.obj.visible = uniforms.uAlpha.value > 0.01;
2156
2750
  continue;
2157
2751
  }
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
2752
  if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2164
2753
  uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2165
2754
  item.obj.visible = uniforms.uAlpha.value > 0.01;
@@ -2192,8 +2781,8 @@ function createEngine({
2192
2781
  const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2193
2782
  if (l.level === 1) {
2194
2783
  let rot = 0;
2195
- const blend = globalUniforms.uBlend.value;
2196
- if (blend > 0.5) {
2784
+ const isWideAngle = currentProjection.id !== "perspective";
2785
+ if (isWideAngle) {
2197
2786
  const dx = l.sX - screenW / 2;
2198
2787
  const dy = l.sY - screenH / 2;
2199
2788
  rot = Math.atan2(-dy, -dx) - Math.PI / 2;
@@ -2201,7 +2790,7 @@ function createEngine({
2201
2790
  l.uniforms.uAngle.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2202
2791
  }
2203
2792
  if (l.level === 2) {
2204
- if (showBooks || isSpecial) {
2793
+ {
2205
2794
  target2 = 1;
2206
2795
  occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2207
2796
  }
@@ -2223,6 +2812,17 @@ function createEngine({
2223
2812
  }
2224
2813
  }
2225
2814
  }
2815
+ if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2816
+ const node = l.item.node;
2817
+ if (node.level === 3) {
2818
+ target2 = 0;
2819
+ } else if (node.level === 2 || node.level === 2.5) {
2820
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2821
+ if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2822
+ target2 = 0;
2823
+ }
2824
+ }
2825
+ }
2226
2826
  l.uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2227
2827
  l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2228
2828
  }
@@ -2237,6 +2837,15 @@ function createEngine({
2237
2837
  window.removeEventListener("mousemove", onMouseMove);
2238
2838
  window.removeEventListener("mouseup", onMouseUp);
2239
2839
  el.removeEventListener("wheel", onWheel);
2840
+ el.removeEventListener("mouseleave", onWindowBlur);
2841
+ window.removeEventListener("blur", onWindowBlur);
2842
+ el.removeEventListener("touchstart", onTouchStart);
2843
+ el.removeEventListener("touchmove", onTouchMove);
2844
+ el.removeEventListener("touchend", onTouchEnd);
2845
+ el.removeEventListener("touchcancel", onTouchCancel);
2846
+ el.removeEventListener("gesturestart", onGesturePrevent);
2847
+ el.removeEventListener("gesturechange", onGesturePrevent);
2848
+ el.removeEventListener("gestureend", onGesturePrevent);
2240
2849
  }
2241
2850
  function dispose() {
2242
2851
  stop();
@@ -2257,7 +2866,30 @@ function createEngine({
2257
2866
  function setOrderRevealEnabled(enabled) {
2258
2867
  orderRevealEnabled = enabled;
2259
2868
  }
2260
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled };
2869
+ function flyTo(nodeId, targetFov) {
2870
+ const node = nodeById.get(nodeId);
2871
+ if (!node) return;
2872
+ const pos = getPosition(node).normalize();
2873
+ flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2874
+ flyToTargetLon = Math.atan2(pos.x, -pos.z);
2875
+ flyToTargetFov = targetFov ?? ENGINE_CONFIG.minFov;
2876
+ flyToActive = true;
2877
+ state.velocityX = 0;
2878
+ state.velocityY = 0;
2879
+ }
2880
+ function setHierarchyFilter(filter) {
2881
+ currentFilter = filter;
2882
+ if (filter) {
2883
+ filterTestamentIndex = filter.testament && testamentToIndex.has(filter.testament) ? testamentToIndex.get(filter.testament) : -1;
2884
+ filterDivisionIndex = filter.division && divisionToIndex.has(filter.division) ? divisionToIndex.get(filter.division) : -1;
2885
+ filterBookIndex = filter.bookKey && bookIdToIndex.has(`B:${filter.bookKey}`) ? bookIdToIndex.get(`B:${filter.bookKey}`) : -1;
2886
+ } else {
2887
+ filterTestamentIndex = -1;
2888
+ filterDivisionIndex = -1;
2889
+ filterBookIndex = -1;
2890
+ }
2891
+ }
2892
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
2261
2893
  }
2262
2894
  var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
2263
2895
  var init_createEngine = __esm({
@@ -2265,20 +2897,29 @@ var init_createEngine = __esm({
2265
2897
  init_layout();
2266
2898
  init_materials();
2267
2899
  init_ConstellationArtworkLayer();
2900
+ init_projections();
2901
+ init_fader();
2268
2902
  ENGINE_CONFIG = {
2269
- minFov: 10,
2270
- maxFov: 165,
2271
- defaultFov: 80,
2903
+ minFov: 1,
2904
+ maxFov: 135,
2905
+ defaultFov: 50,
2272
2906
  dragSpeed: 125e-5,
2273
2907
  inertiaDamping: 0.92,
2274
- blendStart: 60,
2275
- blendEnd: 165,
2276
- zenithStartFov: 110,
2277
- zenithStrength: 0.02,
2908
+ blendStart: 35,
2909
+ blendEnd: 83,
2910
+ zenithStartFov: 75,
2911
+ zenithStrength: 0.15,
2278
2912
  horizonLockStrength: 0.05,
2279
2913
  edgePanThreshold: 0.15,
2280
2914
  edgePanMaxSpeed: 0.02,
2281
- edgePanDelay: 250
2915
+ edgePanDelay: 250,
2916
+ // Touch-specific
2917
+ touchInertiaDamping: 0.85,
2918
+ // Snappier than mouse (0.92)
2919
+ tapMaxDuration: 300,
2920
+ // ms
2921
+ tapMaxDistance: 10
2922
+ // px
2282
2923
  };
2283
2924
  ORDER_REVEAL_CONFIG = {
2284
2925
  globalDim: 0.85,
@@ -2296,7 +2937,10 @@ var StarMap = react.forwardRef(
2296
2937
  getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2297
2938
  setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2298
2939
  setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2299
- setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled)
2940
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled),
2941
+ setHierarchyFilter: (filter) => engineRef.current?.setHierarchyFilter?.(filter),
2942
+ flyTo: (nodeId, targetFov) => engineRef.current?.flyTo?.(nodeId, targetFov),
2943
+ setProjection: (id) => engineRef.current?.setProjection?.(id)
2300
2944
  }));
2301
2945
  react.useEffect(() => {
2302
2946
  let disposed = false;
@@ -31573,6 +32217,9 @@ function generateArrangement(bible, options = {}) {
31573
32217
  return arrangement;
31574
32218
  }
31575
32219
 
32220
+ // src/index.ts
32221
+ init_projections();
32222
+
31576
32223
  exports.StarMap = StarMap;
31577
32224
  exports.bibleToSceneModel = bibleToSceneModel;
31578
32225
  exports.defaultGenerateOptions = defaultGenerateOptions;