@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.js CHANGED
@@ -319,45 +319,60 @@ var init_shaders = __esm({
319
319
  uniform float uScale;
320
320
  uniform float uAspect;
321
321
  uniform float uBlend;
322
+ uniform int uProjectionType;
322
323
 
323
324
  vec4 smartProject(vec4 viewPos) {
324
325
  vec3 dir = normalize(viewPos.xyz);
325
326
  float dist = length(viewPos.xyz);
326
- float zLinear = max(0.01, -dir.z);
327
- float kStereo = 2.0 / (1.0 - dir.z);
328
- float kLinear = 1.0 / zLinear;
329
- float k = mix(kLinear, kStereo, uBlend);
330
- vec2 projected = vec2(k * dir.x, k * dir.y);
331
- projected *= uScale;
332
- projected.x /= uAspect;
333
- float zMetric = -1.0 + (dist / 15000.0);
334
-
327
+ float k;
328
+
335
329
  // Radial Clipping: Push clipped points off-screen in their natural direction
336
330
  // to prevent lines "darting" across the center.
337
331
  vec2 escapeDir = (length(dir.xy) > 0.0001) ? normalize(dir.xy) : vec2(1.0, 1.0);
338
332
  vec2 escapePos = escapeDir * 10000.0;
339
333
 
340
- // Clip backward facing points in fisheye mode
341
- if (uBlend > 0.5 && dir.z > 0.4) return vec4(escapePos, 10.0, 1.0);
342
- // Clip very close points in linear mode
343
- if (uBlend < 0.1 && dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
344
-
334
+ if (uProjectionType == 0) {
335
+ // Perspective
336
+ if (dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
337
+ k = 1.0 / max(0.01, -dir.z);
338
+ } else if (uProjectionType == 1) {
339
+ // Stereographic \u2014 tighter clip to prevent stretch near singularity
340
+ if (dir.z > 0.1) return vec4(escapePos, 10.0, 1.0);
341
+ k = 2.0 / (1.0 - dir.z);
342
+ } else {
343
+ // Blended (auto-blend behavior)
344
+ float zLinear = max(0.01, -dir.z);
345
+ float kStereo = 2.0 / (1.0 - dir.z);
346
+ float kLinear = 1.0 / zLinear;
347
+ k = mix(kLinear, kStereo, uBlend);
348
+
349
+ // Tighter clip threshold that scales with blend factor
350
+ float clipZ = mix(-0.1, 0.1, uBlend);
351
+ if (dir.z > clipZ) return vec4(escapePos, 10.0, 1.0);
352
+ }
353
+
354
+ vec2 projected = vec2(k * dir.x, k * dir.y);
355
+ projected *= uScale;
356
+ projected.x /= uAspect;
357
+ float zMetric = -1.0 + (dist / 15000.0);
358
+
345
359
  return vec4(projected, zMetric, 1.0);
346
360
  }
347
361
  `;
348
362
  MASK_CHUNK = `
349
363
  uniform float uAspect;
350
364
  uniform float uBlend;
365
+ uniform int uProjectionType;
351
366
  varying vec2 vScreenPos;
352
367
  float getMaskAlpha() {
353
- if (uBlend < 0.1) return 1.0;
368
+ // No artificial circular mask \u2014 the horizon, atmosphere, and ground
369
+ // define the dome boundary naturally (as Stellarium does).
370
+ // Only apply a minimal edge softening to catch stray back-face artifacts.
354
371
  vec2 p = vScreenPos;
355
372
  p.x *= uAspect;
356
373
  float dist = length(p);
357
- float t = smoothstep(0.75, 1.0, uBlend);
358
- float currentRadius = mix(2.5, 1.0, t);
359
- float edgeSoftness = mix(0.5, 0.02, t);
360
- return 1.0 - smoothstep(currentRadius - edgeSoftness, currentRadius, dist);
374
+ // Gentle falloff only at extreme screen edges (beyond NDC ~1.8)
375
+ return 1.0 - smoothstep(1.8, 2.0, dist);
361
376
  }
362
377
  `;
363
378
  }
@@ -390,13 +405,15 @@ var init_materials = __esm({
390
405
  uScale: { value: 1 },
391
406
  uAspect: { value: 1 },
392
407
  uBlend: { value: 0 },
408
+ uProjectionType: { value: 2 },
409
+ // 0=perspective, 1=stereographic, 2=blended
393
410
  uTime: { value: 0 },
394
411
  // Atmosphere Settings
395
412
  uAtmGlow: { value: 1 },
396
413
  uAtmDark: { value: 0.6 },
397
414
  uAtmExtinction: { value: 4 },
398
415
  uAtmTwinkle: { value: 0.8 },
399
- uColorHorizon: { value: new THREE5.Color(2768476) },
416
+ uColorHorizon: { value: new THREE5.Color(3825292) },
400
417
  uColorZenith: { value: new THREE5.Color(132104) }
401
418
  };
402
419
  }
@@ -595,6 +612,10 @@ var init_ConstellationArtworkLayer = __esm({
595
612
  this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
596
613
  });
597
614
  }
615
+ _globalOpacity = 1;
616
+ setGlobalOpacity(v) {
617
+ this._globalOpacity = v;
618
+ }
598
619
  update(fov, showArt) {
599
620
  this.root.visible = showArt;
600
621
  if (!showArt) return;
@@ -609,7 +630,7 @@ var init_ConstellationArtworkLayer = __esm({
609
630
  const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
610
631
  opacity = THREE5.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
611
632
  }
612
- opacity = Math.min(Math.max(opacity, 0), 1);
633
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
613
634
  item.material.uniforms.uOpacity.value = opacity;
614
635
  }
615
636
  }
@@ -635,6 +656,167 @@ var init_ConstellationArtworkLayer = __esm({
635
656
  }
636
657
  });
637
658
 
659
+ // src/engine/projections.ts
660
+ var PerspectiveProjection, StereographicProjection, BlendedProjection, PROJECTIONS;
661
+ var init_projections = __esm({
662
+ "src/engine/projections.ts"() {
663
+ PerspectiveProjection = class {
664
+ id = "perspective";
665
+ label = "Perspective";
666
+ maxFov = 160;
667
+ glslProjectionType = 0;
668
+ forward(dir) {
669
+ if (dir.z > -0.1) return null;
670
+ const k = 1 / Math.max(0.01, -dir.z);
671
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
672
+ }
673
+ inverse(uvX, uvY, fovRad) {
674
+ const halfHeight = Math.tan(fovRad / 2);
675
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
676
+ const theta = Math.atan(r * halfHeight);
677
+ const phi = Math.atan2(uvY, uvX);
678
+ const sinT = Math.sin(theta);
679
+ return {
680
+ x: sinT * Math.cos(phi),
681
+ y: sinT * Math.sin(phi),
682
+ z: -Math.cos(theta)
683
+ };
684
+ }
685
+ getScale(fovRad) {
686
+ return 1 / Math.tan(fovRad / 2);
687
+ }
688
+ isClipped(dirZ) {
689
+ return dirZ > -0.1;
690
+ }
691
+ };
692
+ StereographicProjection = class {
693
+ id = "stereographic";
694
+ label = "Stereographic";
695
+ maxFov = 360;
696
+ glslProjectionType = 1;
697
+ forward(dir) {
698
+ if (dir.z > 0.4) return null;
699
+ const k = 2 / (1 - dir.z);
700
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
701
+ }
702
+ inverse(uvX, uvY, fovRad) {
703
+ const halfHeight = 2 * Math.tan(fovRad / 4);
704
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
705
+ const theta = 2 * Math.atan(r * halfHeight / 2);
706
+ const phi = Math.atan2(uvY, uvX);
707
+ const sinT = Math.sin(theta);
708
+ return {
709
+ x: sinT * Math.cos(phi),
710
+ y: sinT * Math.sin(phi),
711
+ z: -Math.cos(theta)
712
+ };
713
+ }
714
+ getScale(fovRad) {
715
+ return 1 / (2 * Math.tan(fovRad / 4));
716
+ }
717
+ isClipped(dirZ) {
718
+ return dirZ > 0.4;
719
+ }
720
+ };
721
+ BlendedProjection = class {
722
+ id = "blended";
723
+ label = "Blended (Auto)";
724
+ maxFov = 165;
725
+ glslProjectionType = 2;
726
+ /** FOV thresholds for blend transition (degrees) */
727
+ blendStart;
728
+ blendEnd;
729
+ /** Current blend factor, updated via setFov() */
730
+ blend = 0;
731
+ constructor(blendStart = 40, blendEnd = 100) {
732
+ this.blendStart = blendStart;
733
+ this.blendEnd = blendEnd;
734
+ }
735
+ /** Call this each frame / when FOV changes so forward/inverse stay in sync */
736
+ setFov(fovDeg) {
737
+ if (fovDeg <= this.blendStart) {
738
+ this.blend = 0;
739
+ return;
740
+ }
741
+ if (fovDeg >= this.blendEnd) {
742
+ this.blend = 1;
743
+ return;
744
+ }
745
+ const t = (fovDeg - this.blendStart) / (this.blendEnd - this.blendStart);
746
+ this.blend = t * t * (3 - 2 * t);
747
+ }
748
+ getBlend() {
749
+ return this.blend;
750
+ }
751
+ forward(dir) {
752
+ if (this.blend > 0.5 && dir.z > 0.4) return null;
753
+ if (this.blend < 0.1 && dir.z > -0.1) return null;
754
+ const kLinear = 1 / Math.max(0.01, -dir.z);
755
+ const kStereo = 2 / (1 - dir.z);
756
+ const k = kLinear * (1 - this.blend) + kStereo * this.blend;
757
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
758
+ }
759
+ inverse(uvX, uvY, fovRad) {
760
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
761
+ const halfHeightLin = Math.tan(fovRad / 2);
762
+ const thetaLin = Math.atan(r * halfHeightLin);
763
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
764
+ const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
765
+ const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
766
+ const phi = Math.atan2(uvY, uvX);
767
+ const sinT = Math.sin(theta);
768
+ return {
769
+ x: sinT * Math.cos(phi),
770
+ y: sinT * Math.sin(phi),
771
+ z: -Math.cos(theta)
772
+ };
773
+ }
774
+ getScale(fovRad) {
775
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
776
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
777
+ return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
778
+ }
779
+ isClipped(dirZ) {
780
+ if (this.blend > 0.5) return dirZ > 0.4;
781
+ if (this.blend < 0.1) return dirZ > -0.1;
782
+ return false;
783
+ }
784
+ };
785
+ PROJECTIONS = {
786
+ perspective: () => new PerspectiveProjection(),
787
+ stereographic: () => new StereographicProjection()
788
+ };
789
+ }
790
+ });
791
+
792
+ // src/engine/fader.ts
793
+ var Fader;
794
+ var init_fader = __esm({
795
+ "src/engine/fader.ts"() {
796
+ Fader = class {
797
+ target = false;
798
+ value = 0;
799
+ duration;
800
+ constructor(duration = 0.3) {
801
+ this.duration = duration;
802
+ }
803
+ update(dt) {
804
+ const goal = this.target ? 1 : 0;
805
+ if (this.value === goal) return;
806
+ const speed = 1 / this.duration;
807
+ const step = speed * dt;
808
+ const diff = goal - this.value;
809
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
810
+ }
811
+ /** Smoothstep-eased value for perceptually smooth transitions */
812
+ get eased() {
813
+ const v = this.value;
814
+ return v * v * (3 - 2 * v);
815
+ }
816
+ };
817
+ }
818
+ });
819
+
638
820
  // src/engine/createEngine.ts
639
821
  var createEngine_exports = {};
640
822
  __export(createEngine_exports, {
@@ -652,9 +834,21 @@ function createEngine({
652
834
  let orderRevealEnabled = true;
653
835
  let activeBookIndex = -1;
654
836
  let orderRevealStrength = 0;
837
+ let flyToActive = false;
838
+ let flyToTargetLon = 0;
839
+ let flyToTargetLat = 0;
840
+ let flyToTargetFov = ENGINE_CONFIG.minFov;
841
+ const FLY_TO_SPEED = 0.04;
842
+ let currentFilter = null;
843
+ let filterStrength = 0;
844
+ let filterTestamentIndex = -1;
845
+ let filterDivisionIndex = -1;
846
+ let filterBookIndex = -1;
655
847
  const hoverCooldowns = /* @__PURE__ */ new Map();
656
848
  const COOLDOWN_MS = 2e3;
657
849
  const bookIdToIndex = /* @__PURE__ */ new Map();
850
+ const testamentToIndex = /* @__PURE__ */ new Map();
851
+ const divisionToIndex = /* @__PURE__ */ new Map();
658
852
  const renderer = new THREE5.WebGLRenderer({ antialias: true, alpha: false });
659
853
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
660
854
  renderer.setSize(container.clientWidth, container.clientHeight);
@@ -693,10 +887,21 @@ function createEngine({
693
887
  draggedStarIndex: -1,
694
888
  draggedDist: 2e3,
695
889
  draggedGroup: null,
696
- tempArrangement: {}
890
+ tempArrangement: {},
891
+ // Touch state
892
+ touchCount: 0,
893
+ touchStartTime: 0,
894
+ touchStartX: 0,
895
+ touchStartY: 0,
896
+ touchMoved: false,
897
+ pinchStartDistance: 0,
898
+ pinchStartFov: ENGINE_CONFIG.defaultFov,
899
+ pinchCenterX: 0,
900
+ pinchCenterY: 0
697
901
  };
698
902
  const mouseNDC = new THREE5.Vector2();
699
903
  let isMouseInWindow = false;
904
+ let isTouchDevice = false;
700
905
  let edgeHoverStart = 0;
701
906
  let handlers = { onSelect, onHover, onArrangementChange, onFovChange };
702
907
  let currentConfig;
@@ -704,68 +909,53 @@ function createEngine({
704
909
  function mix(a, b, t) {
705
910
  return a * (1 - t) + b * t;
706
911
  }
707
- function getBlendFactor(fov) {
708
- if (fov <= ENGINE_CONFIG.blendStart) return 0;
709
- if (fov >= ENGINE_CONFIG.blendEnd) return 1;
710
- let t = (fov - ENGINE_CONFIG.blendStart) / (ENGINE_CONFIG.blendEnd - ENGINE_CONFIG.blendStart);
711
- return t * t * (3 - 2 * t);
912
+ let currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
913
+ function syncProjectionState() {
914
+ if (currentProjection instanceof BlendedProjection) {
915
+ currentProjection.setFov(state.fov);
916
+ globalUniforms.uBlend.value = currentProjection.getBlend();
917
+ }
918
+ globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
712
919
  }
713
920
  function updateUniforms() {
714
- const blend = getBlendFactor(state.fov);
715
- globalUniforms.uBlend.value = blend;
921
+ syncProjectionState();
716
922
  const fovRad = state.fov * Math.PI / 180;
717
- const scaleLinear = 1 / Math.tan(fovRad / 2);
718
- const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
719
- globalUniforms.uScale.value = mix(scaleLinear, scaleStereo, blend);
720
- globalUniforms.uAspect.value = camera.aspect;
923
+ let scale = currentProjection.getScale(fovRad);
924
+ const aspect = camera.aspect;
925
+ if (currentConfig?.fitProjection) {
926
+ if (aspect > 1) {
927
+ scale /= aspect;
928
+ }
929
+ }
930
+ globalUniforms.uScale.value = scale;
931
+ globalUniforms.uAspect.value = aspect;
721
932
  camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
722
933
  camera.updateProjectionMatrix();
723
934
  }
724
935
  function getMouseViewVector(fovDeg, aspectRatio) {
725
- const blend = getBlendFactor(fovDeg);
936
+ syncProjectionState();
726
937
  const fovRad = fovDeg * Math.PI / 180;
727
938
  const uvX = mouseNDC.x * aspectRatio;
728
939
  const uvY = mouseNDC.y;
729
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
730
- const halfHeightLinear = Math.tan(fovRad / 2);
731
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
732
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
733
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
734
- const theta = mix(theta_lin, theta_str, blend);
735
- const phi = Math.atan2(uvY, uvX);
736
- const sinTheta = Math.sin(theta);
737
- const cosTheta = Math.cos(theta);
738
- return new THREE5.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
940
+ const v = currentProjection.inverse(uvX, uvY, fovRad);
941
+ return new THREE5.Vector3(v.x, v.y, v.z).normalize();
739
942
  }
740
943
  function getMouseWorldVector(pixelX, pixelY, width, height) {
741
944
  const aspect = width / height;
742
945
  const ndcX = pixelX / width * 2 - 1;
743
946
  const ndcY = -(pixelY / height) * 2 + 1;
744
- const blend = getBlendFactor(state.fov);
947
+ syncProjectionState();
745
948
  const fovRad = state.fov * Math.PI / 180;
746
- const uvX = ndcX * aspect;
747
- const uvY = ndcY;
748
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
749
- const halfHeightLinear = Math.tan(fovRad / 2);
750
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
751
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
752
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
753
- const theta = mix(theta_lin, theta_str, blend);
754
- const phi = Math.atan2(uvY, uvX);
755
- const sinTheta = Math.sin(theta);
756
- const cosTheta = Math.cos(theta);
757
- const vView = new THREE5.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
949
+ const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
950
+ const vView = new THREE5.Vector3(v.x, v.y, v.z).normalize();
758
951
  return vView.applyQuaternion(camera.quaternion);
759
952
  }
760
953
  function smartProjectJS(worldPos) {
761
954
  const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
762
955
  const dir = viewPos.clone().normalize();
763
- const zLinear = Math.max(0.01, -dir.z);
764
- const kStereo = 2 / (1 - dir.z);
765
- const kLinear = 1 / zLinear;
766
- const blend = globalUniforms.uBlend.value;
767
- const k = mix(kLinear, kStereo, blend);
768
- return { x: k * dir.x, y: k * dir.y, z: dir.z };
956
+ const result = currentProjection.forward(dir);
957
+ if (!result) return { x: 0, y: 0, z: dir.z };
958
+ return result;
769
959
  }
770
960
  const groundGroup = new THREE5.Group();
771
961
  scene.add(groundGroup);
@@ -775,10 +965,8 @@ function createEngine({
775
965
  const geometry = new THREE5.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
776
966
  const material = createSmartMaterial({
777
967
  uniforms: {
778
- color: { value: new THREE5.Color(131587) },
779
- // Very dark almost black
780
- fogColor: { value: new THREE5.Color(331812) }
781
- // Matches atmosphere bot color
968
+ color: { value: new THREE5.Color(65794) },
969
+ fogColor: { value: new THREE5.Color(663098) }
782
970
  },
783
971
  vertexShaderBody: `
784
972
  varying vec3 vPos;
@@ -804,24 +992,30 @@ function createEngine({
804
992
  // Procedural Horizon (Mountains)
805
993
  float angle = atan(vPos.z, vPos.x);
806
994
 
807
- // Simple FBM-like terrain
995
+ // FBM-like terrain with increased amplitude
808
996
  float h = 0.0;
809
- h += sin(angle * 6.0) * 20.0;
810
- h += sin(angle * 13.0 + 1.0) * 10.0;
811
- h += sin(angle * 29.0 + 2.0) * 5.0;
812
- h += sin(angle * 63.0 + 4.0) * 2.0;
813
-
814
- // Base horizon offset (lift slightly)
815
- float terrainHeight = h + 10.0;
816
-
997
+ h += sin(angle * 6.0) * 35.0;
998
+ h += sin(angle * 13.0 + 1.0) * 18.0;
999
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1000
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1001
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1002
+
1003
+ float terrainHeight = h + 12.0;
1004
+
817
1005
  if (vPos.y > terrainHeight) discard;
818
-
819
- // Atmospheric Haze / Fog on the ground
820
- // Mix ground color with fog color based on vertical height (fade into horizon)
821
- // Closer to horizon (higher y) -> more fog
822
- float fogFactor = smoothstep(-100.0, terrainHeight, vPos.y);
823
- vec3 finalCol = mix(color, fogColor, fogFactor * 0.5);
824
-
1006
+
1007
+ // Atmospheric rim glow just below terrain peaks
1008
+ float rimDist = terrainHeight - vPos.y;
1009
+ float rim = exp(-rimDist * 0.15) * 0.4;
1010
+ vec3 rimColor = fogColor * 1.5;
1011
+
1012
+ // Atmospheric haze \u2014 stronger near horizon
1013
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1014
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1015
+
1016
+ // Add rim glow near terrain peaks
1017
+ finalCol += rimColor * rim;
1018
+
825
1019
  gl_FragColor = vec4(finalCol, 1.0);
826
1020
  }
827
1021
  `,
@@ -859,19 +1053,25 @@ function createEngine({
859
1053
 
860
1054
  // Altitude angle (Y is up)
861
1055
  float h = normalize(vWorldNormal).y;
862
-
863
- // Gradient Logic
864
- // 1. Base gradient from Horizon to Zenith
865
- float t = smoothstep(-0.1, 0.5, h);
866
-
1056
+
1057
+ // 1. Base gradient from Horizon to Zenith (wider range)
1058
+ float t = smoothstep(-0.15, 0.7, h);
1059
+
867
1060
  // Non-linear mix for realistic sky falloff
868
- // Zenith darkness adjustment
869
1061
  vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
870
-
871
- // 2. Horizon Glow Band (Simulate scattering/haze layer)
872
- float horizonBand = exp(-15.0 * abs(h - 0.02)); // Sharp peak near 0
1062
+
1063
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
1064
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1065
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
1066
+
1067
+ // 3. Primary horizon glow band (wider than before)
1068
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
873
1069
  skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
874
1070
 
1071
+ // 4. Warm secondary glow (light pollution / sodium scatter)
1072
+ float warmGlow = exp(-8.0 * abs(h));
1073
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1074
+
875
1075
  gl_FragColor = vec4(skyColor, 1.0);
876
1076
  }
877
1077
  `,
@@ -909,7 +1109,24 @@ function createEngine({
909
1109
  positions.push(x, y, z);
910
1110
  const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
911
1111
  sizes.push(size);
912
- colors.push(1, 1, 1);
1112
+ const temp = Math.random();
1113
+ let cr, cg, cb;
1114
+ if (temp < 0.15) {
1115
+ cr = 0.7 + temp * 2;
1116
+ cg = 0.8 + temp;
1117
+ cb = 1;
1118
+ } else if (temp < 0.6) {
1119
+ const t = (temp - 0.15) / 0.45;
1120
+ cr = 1;
1121
+ cg = 1 - t * 0.1;
1122
+ cb = 1 - t * 0.3;
1123
+ } else {
1124
+ const t = (temp - 0.6) / 0.4;
1125
+ cr = 1;
1126
+ cg = 0.85 - t * 0.35;
1127
+ cb = 0.7 - t * 0.35;
1128
+ }
1129
+ colors.push(cr, cg, cb);
913
1130
  }
914
1131
  geometry.setAttribute("position", new THREE5.Float32BufferAttribute(positions, 3));
915
1132
  geometry.setAttribute("size", new THREE5.Float32BufferAttribute(sizes, 1));
@@ -917,51 +1134,60 @@ function createEngine({
917
1134
  const material = createSmartMaterial({
918
1135
  uniforms: {
919
1136
  pixelRatio: { value: renderer.getPixelRatio() },
920
- uScale: globalUniforms.uScale
1137
+ uScale: globalUniforms.uScale,
1138
+ uTime: globalUniforms.uTime
921
1139
  },
922
1140
  vertexShaderBody: `
923
- attribute float size;
924
- attribute vec3 color;
925
- varying vec3 vColor;
926
- uniform float pixelRatio;
927
-
1141
+ attribute float size;
1142
+ attribute vec3 color;
1143
+ varying vec3 vColor;
1144
+ uniform float pixelRatio;
1145
+
928
1146
  uniform float uAtmExtinction;
1147
+ uniform float uAtmTwinkle;
1148
+ uniform float uTime;
929
1149
 
930
- void main() {
1150
+ void main() {
931
1151
  vec3 nPos = normalize(position);
932
1152
  float altitude = nPos.y;
933
-
934
- // Simple Extinction & Horizon Fade
1153
+
1154
+ // Extinction & Horizon Fade
935
1155
  float horizonFade = smoothstep(-0.1, 0.1, altitude);
936
1156
  float airmass = 1.0 / (max(0.05, altitude + 0.05));
937
1157
  float extinction = exp(-uAtmExtinction * 0.15 * airmass);
938
1158
 
939
- // Boost intensity significantly (3.0x)
940
- vColor = color * 3.0 * extinction * horizonFade;
1159
+ // Scintillation (twinkling) \u2014 stronger near horizon
1160
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1161
+ float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1162
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
941
1163
 
942
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
943
- gl_Position = smartProject(mvPosition);
944
- vScreenPos = gl_Position.xy / gl_Position.w;
945
-
946
- // Non-linear scale with zoom to keep stars looking like points
947
- // pow(uScale, 0.5) prevents them from getting too large at low FOV
948
- float zoomScale = pow(uScale, 0.5);
949
-
950
- gl_PointSize = size * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade;
1164
+ vColor = color * 3.0 * extinction * horizonFade * scintillation;
1165
+
1166
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1167
+ gl_Position = smartProject(mvPosition);
1168
+ vScreenPos = gl_Position.xy / gl_Position.w;
1169
+
1170
+ float zoomScale = pow(uScale, 0.5);
1171
+ float perceptualSize = pow(size, 0.55);
1172
+ gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
951
1173
  }
952
1174
  `,
953
1175
  fragmentShader: `
954
- varying vec3 vColor;
955
- void main() {
956
- vec2 coord = gl_PointCoord - vec2(0.5);
957
- float dist = length(coord) * 2.0;
958
- if (dist > 1.0) discard;
959
- float alphaMask = getMaskAlpha();
960
- if (alphaMask < 0.01) discard;
961
-
962
- // Sharp falloff for intense point look
963
- float alpha = exp(-4.0 * dist * dist);
964
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1176
+ varying vec3 vColor;
1177
+ void main() {
1178
+ vec2 coord = gl_PointCoord - vec2(0.5);
1179
+ float d = length(coord) * 2.0;
1180
+ if (d > 1.0) discard;
1181
+ float alphaMask = getMaskAlpha();
1182
+ if (alphaMask < 0.01) discard;
1183
+
1184
+ // Stellarium-style: sharp core + soft glow
1185
+ float core = smoothstep(0.8, 0.4, d);
1186
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1187
+ float k = core + glow;
1188
+
1189
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.5);
1190
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
965
1191
  }
966
1192
  `,
967
1193
  transparent: true,
@@ -1034,6 +1260,9 @@ function createEngine({
1034
1260
  let constellationLines = null;
1035
1261
  let boundaryLines = null;
1036
1262
  let starPoints = null;
1263
+ const linesFader = new Fader(0.4);
1264
+ const artFader = new Fader(0.5);
1265
+ let lastTickTime = 0;
1037
1266
  function clearRoot() {
1038
1267
  for (const child of [...root.children]) {
1039
1268
  root.remove(child);
@@ -1104,6 +1333,8 @@ function createEngine({
1104
1333
  function buildFromModel(model, cfg) {
1105
1334
  clearRoot();
1106
1335
  bookIdToIndex.clear();
1336
+ testamentToIndex.clear();
1337
+ divisionToIndex.clear();
1107
1338
  scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5.Color(cfg.background) : new THREE5.Color(0);
1108
1339
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
1109
1340
  const laidOut = computeLayoutPositions(model, layoutCfg);
@@ -1137,6 +1368,8 @@ function createEngine({
1137
1368
  const starPhases = [];
1138
1369
  const starBookIndices = [];
1139
1370
  const starChapterIndices = [];
1371
+ const starTestamentIndices = [];
1372
+ const starDivisionIndices = [];
1140
1373
  const SPECTRAL_COLORS = [
1141
1374
  new THREE5.Color(14544639),
1142
1375
  // O - Blueish White
@@ -1194,12 +1427,32 @@ function createEngine({
1194
1427
  let cIdx = 0;
1195
1428
  if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1196
1429
  starChapterIndices.push(cIdx);
1430
+ let tIdx = -1;
1431
+ if (n.meta?.testament) {
1432
+ const tName = n.meta.testament;
1433
+ if (!testamentToIndex.has(tName)) {
1434
+ testamentToIndex.set(tName, testamentToIndex.size + 1);
1435
+ }
1436
+ tIdx = testamentToIndex.get(tName);
1437
+ }
1438
+ starTestamentIndices.push(tIdx);
1439
+ let dIdx = -1;
1440
+ if (n.meta?.division) {
1441
+ const dName = n.meta.division;
1442
+ if (!divisionToIndex.has(dName)) {
1443
+ divisionToIndex.set(dName, divisionToIndex.size + 1);
1444
+ }
1445
+ dIdx = divisionToIndex.get(dName);
1446
+ }
1447
+ starDivisionIndices.push(dIdx);
1197
1448
  }
1198
1449
  if (n.level === 1 || n.level === 2 || n.level === 3) {
1199
1450
  let color = "#ffffff";
1200
1451
  if (n.level === 1) color = "#38bdf8";
1201
- else if (n.level === 2) color = "#cbd5e1";
1202
- else if (n.level === 3) color = "#94a3b8";
1452
+ else if (n.level === 2) {
1453
+ const bookKey = n.meta?.bookKey;
1454
+ color = bookKey && cfg.labelColors?.[bookKey] || "#cbd5e1";
1455
+ } else if (n.level === 3) color = "#94a3b8";
1203
1456
  let labelText = n.label;
1204
1457
  if (n.level === 3 && n.meta?.chapter) {
1205
1458
  labelText = String(n.meta.chapter);
@@ -1280,6 +1533,8 @@ function createEngine({
1280
1533
  starGeo.setAttribute("phase", new THREE5.Float32BufferAttribute(starPhases, 1));
1281
1534
  starGeo.setAttribute("bookIndex", new THREE5.Float32BufferAttribute(starBookIndices, 1));
1282
1535
  starGeo.setAttribute("chapterIndex", new THREE5.Float32BufferAttribute(starChapterIndices, 1));
1536
+ starGeo.setAttribute("testamentIndex", new THREE5.Float32BufferAttribute(starTestamentIndices, 1));
1537
+ starGeo.setAttribute("divisionIndex", new THREE5.Float32BufferAttribute(starDivisionIndices, 1));
1283
1538
  const starMat = createSmartMaterial({
1284
1539
  uniforms: {
1285
1540
  pixelRatio: { value: renderer.getPixelRatio() },
@@ -1292,7 +1547,12 @@ function createEngine({
1292
1547
  ORDER_REVEAL_CONFIG.pulseDuration,
1293
1548
  ORDER_REVEAL_CONFIG.delayPerChapter,
1294
1549
  ORDER_REVEAL_CONFIG.pulseAmplitude
1295
- ) }
1550
+ ) },
1551
+ uFilterTestamentIndex: { value: -1 },
1552
+ uFilterDivisionIndex: { value: -1 },
1553
+ uFilterBookIndex: { value: -1 },
1554
+ uFilterStrength: { value: 0 },
1555
+ uFilterDimFactor: { value: 0.08 }
1296
1556
  },
1297
1557
  vertexShaderBody: `
1298
1558
  attribute float size;
@@ -1300,10 +1560,12 @@ function createEngine({
1300
1560
  attribute float phase;
1301
1561
  attribute float bookIndex;
1302
1562
  attribute float chapterIndex;
1563
+ attribute float testamentIndex;
1564
+ attribute float divisionIndex;
1565
+
1566
+ varying vec3 vColor;
1567
+ uniform float pixelRatio;
1303
1568
 
1304
- varying vec3 vColor;
1305
- uniform float pixelRatio;
1306
-
1307
1569
  uniform float uTime;
1308
1570
  uniform float uAtmExtinction;
1309
1571
  uniform float uAtmTwinkle;
@@ -1313,6 +1575,12 @@ function createEngine({
1313
1575
  uniform float uGlobalDimFactor;
1314
1576
  uniform vec3 uPulseParams;
1315
1577
 
1578
+ uniform float uFilterTestamentIndex;
1579
+ uniform float uFilterDivisionIndex;
1580
+ uniform float uFilterBookIndex;
1581
+ uniform float uFilterStrength;
1582
+ uniform float uFilterDimFactor;
1583
+
1316
1584
  void main() {
1317
1585
  vec3 nPos = normalize(position);
1318
1586
 
@@ -1347,8 +1615,21 @@ function createEngine({
1347
1615
 
1348
1616
  float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1349
1617
 
1618
+ // --- Hierarchy Filter ---
1619
+ float filtered = 0.0;
1620
+ if (uFilterTestamentIndex >= 0.0) {
1621
+ filtered = 1.0 - step(0.5, 1.0 - abs(testamentIndex - uFilterTestamentIndex));
1622
+ }
1623
+ if (uFilterDivisionIndex >= 0.0 && filtered < 0.5) {
1624
+ filtered = 1.0 - step(0.5, 1.0 - abs(divisionIndex - uFilterDivisionIndex));
1625
+ }
1626
+ if (uFilterBookIndex >= 0.0 && filtered < 0.5) {
1627
+ filtered = 1.0 - step(0.5, 1.0 - abs(bookIndex - uFilterBookIndex));
1628
+ }
1629
+ float filterDim = mix(1.0, uFilterDimFactor, uFilterStrength * filtered);
1630
+
1350
1631
  vec3 baseColor = color * extinction * horizonFade * scintillation;
1351
- vColor = baseColor * dimFactor;
1632
+ vColor = baseColor * dimFactor * filterDim;
1352
1633
  vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1353
1634
 
1354
1635
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
@@ -1356,7 +1637,8 @@ function createEngine({
1356
1637
  vScreenPos = gl_Position.xy / gl_Position.w;
1357
1638
 
1358
1639
  float sizeBoost = 1.0 + activePulse * 0.8;
1359
- gl_PointSize = (size * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade;
1640
+ float perceptualSize = pow(size, 0.55);
1641
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
1360
1642
  }
1361
1643
  `,
1362
1644
  fragmentShader: `
@@ -1369,15 +1651,14 @@ function createEngine({
1369
1651
  float alphaMask = getMaskAlpha();
1370
1652
  if (alphaMask < 0.01) discard;
1371
1653
 
1372
- float dd = d * d;
1373
- // Stellarium Profile
1374
- float core = exp(-20.0 * dd);
1375
- float halo = exp(-4.0 * dd);
1376
-
1377
- vec3 cCore = vec3(1.0) * core * 1.5;
1378
- vec3 cHalo = vColor * halo * 0.6;
1379
-
1380
- gl_FragColor = vec4((cCore + cHalo) * alphaMask, 1.0);
1654
+ // Stellarium-style dual-layer: sharp core + soft glow
1655
+ float core = smoothstep(0.8, 0.4, d);
1656
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1657
+ float k = core + glow;
1658
+
1659
+ // White-hot core blending into coloured halo
1660
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1661
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
1381
1662
  }
1382
1663
  `,
1383
1664
  transparent: true,
@@ -1411,17 +1692,89 @@ function createEngine({
1411
1692
  }
1412
1693
  }
1413
1694
  if (linePoints.length > 0) {
1695
+ const quadPositions = [];
1696
+ const quadUvs = [];
1697
+ const quadIndices = [];
1698
+ const lineWidth = 8;
1699
+ for (let i = 0; i < linePoints.length; i += 6) {
1700
+ const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1701
+ const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
1702
+ const dx = bx - ax, dy = by - ay, dz = bz - az;
1703
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1704
+ if (len < 1e-3) continue;
1705
+ let px = dy * 0 - dz * 1, py = dz * 0 - dx * 0, pz = dx * 1 - dy * 0;
1706
+ const pLen = Math.sqrt(px * px + py * py + pz * pz);
1707
+ if (pLen < 1e-3) {
1708
+ px = 1;
1709
+ py = 0;
1710
+ pz = 0;
1711
+ } else {
1712
+ px /= pLen;
1713
+ py /= pLen;
1714
+ pz /= pLen;
1715
+ }
1716
+ const hw = lineWidth;
1717
+ const baseIdx = quadPositions.length / 3;
1718
+ quadPositions.push(ax - px * hw, ay - py * hw, az - pz * hw);
1719
+ quadUvs.push(0, -1);
1720
+ quadPositions.push(ax + px * hw, ay + py * hw, az + pz * hw);
1721
+ quadUvs.push(0, 1);
1722
+ quadPositions.push(bx - px * hw, by - py * hw, bz - pz * hw);
1723
+ quadUvs.push(1, -1);
1724
+ quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1725
+ quadUvs.push(1, 1);
1726
+ quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1727
+ }
1414
1728
  const lineGeo = new THREE5.BufferGeometry();
1415
- lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(linePoints, 3));
1729
+ lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(quadPositions, 3));
1730
+ lineGeo.setAttribute("lineUv", new THREE5.Float32BufferAttribute(quadUvs, 2));
1731
+ lineGeo.setIndex(quadIndices);
1416
1732
  const lineMat = createSmartMaterial({
1417
- uniforms: { color: { value: new THREE5.Color(11193599) } },
1418
- vertexShaderBody: `uniform vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
1419
- fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.4 * alphaMask); }`,
1733
+ uniforms: {
1734
+ color: { value: new THREE5.Color(11193599) },
1735
+ uLineWidth: { value: 1.5 },
1736
+ uGlowIntensity: { value: 0.3 }
1737
+ },
1738
+ vertexShaderBody: `
1739
+ attribute vec2 lineUv;
1740
+ varying vec2 vLineUv;
1741
+ void main() {
1742
+ vLineUv = lineUv;
1743
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1744
+ gl_Position = smartProject(mvPosition);
1745
+ vScreenPos = gl_Position.xy / gl_Position.w;
1746
+ }
1747
+ `,
1748
+ fragmentShader: `
1749
+ uniform vec3 color;
1750
+ uniform float uLineWidth;
1751
+ uniform float uGlowIntensity;
1752
+ varying vec2 vLineUv;
1753
+ void main() {
1754
+ float alphaMask = getMaskAlpha();
1755
+ if (alphaMask < 0.01) discard;
1756
+
1757
+ float dist = abs(vLineUv.y);
1758
+
1759
+ // Anti-aliased core line
1760
+ float hw = uLineWidth * 0.05;
1761
+ float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1762
+
1763
+ // Soft glow extending outward
1764
+ float glow = (1.0 - dist) * uGlowIntensity;
1765
+
1766
+ float alpha = max(glow, base);
1767
+ if (alpha < 0.005) discard;
1768
+
1769
+ gl_FragColor = vec4(color, alpha * alphaMask);
1770
+ }
1771
+ `,
1420
1772
  transparent: true,
1421
1773
  depthWrite: false,
1422
- blending: THREE5.AdditiveBlending
1774
+ blending: THREE5.AdditiveBlending,
1775
+ side: THREE5.DoubleSide
1423
1776
  });
1424
- constellationLines = new THREE5.LineSegments(lineGeo, lineMat);
1777
+ constellationLines = new THREE5.Mesh(lineGeo, lineMat);
1425
1778
  constellationLines.frustumCulled = false;
1426
1779
  root.add(constellationLines);
1427
1780
  }
@@ -1593,8 +1946,19 @@ function createEngine({
1593
1946
  let lastAppliedLon = void 0;
1594
1947
  let lastAppliedLat = void 0;
1595
1948
  let lastBackdropCount = void 0;
1949
+ function setProjection(id) {
1950
+ if (id === "blended") {
1951
+ currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
1952
+ } else {
1953
+ const factory = PROJECTIONS[id];
1954
+ if (!factory) return;
1955
+ currentProjection = factory();
1956
+ }
1957
+ updateUniforms();
1958
+ }
1596
1959
  function setConfig(cfg) {
1597
1960
  currentConfig = cfg;
1961
+ if (cfg.projection) setProjection(cfg.projection);
1598
1962
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1599
1963
  state.lon = cfg.camera.lon;
1600
1964
  state.targetLon = cfg.camera.lon;
@@ -1684,6 +2048,15 @@ function createEngine({
1684
2048
  Object.assign(arr, state.tempArrangement);
1685
2049
  return arr;
1686
2050
  }
2051
+ function isNodeFiltered(node) {
2052
+ if (!currentFilter) return false;
2053
+ const meta = node.meta;
2054
+ if (!meta) return false;
2055
+ if (currentFilter.testament && meta.testament !== currentFilter.testament) return true;
2056
+ if (currentFilter.division && meta.division !== currentFilter.division) return true;
2057
+ if (currentFilter.bookKey && meta.bookKey !== currentFilter.bookKey) return true;
2058
+ return false;
2059
+ }
1687
2060
  function pick(ev) {
1688
2061
  const rect = renderer.domElement.getBoundingClientRect();
1689
2062
  const mX = ev.clientX - rect.left;
@@ -1695,13 +2068,14 @@ function createEngine({
1695
2068
  const w = rect.width;
1696
2069
  const h = rect.height;
1697
2070
  let closestLabel = null;
1698
- let minLabelDist = 40;
2071
+ const LABEL_THRESHOLD = isTouchDevice ? 48 : 40;
2072
+ let minLabelDist = LABEL_THRESHOLD;
1699
2073
  for (const item of dynamicLabels) {
1700
2074
  if (!item.obj.visible) continue;
2075
+ if (isNodeFiltered(item.node)) continue;
1701
2076
  const pWorld = item.obj.position;
1702
2077
  const pProj = smartProjectJS(pWorld);
1703
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1704
- if (isBehind) continue;
2078
+ if (currentProjection.isClipped(pProj.z)) continue;
1705
2079
  const xNDC = pProj.x * uScale / uAspect;
1706
2080
  const yNDC = pProj.y * uScale;
1707
2081
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1723,8 +2097,7 @@ function createEngine({
1723
2097
  if (!item.mesh.visible) continue;
1724
2098
  const pWorld = item.mesh.position;
1725
2099
  const pProj = smartProjectJS(pWorld);
1726
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1727
- if (isBehind) continue;
2100
+ if (currentProjection.isClipped(pProj.z)) continue;
1728
2101
  const uniforms = item.material.uniforms;
1729
2102
  if (!uniforms || !uniforms.uSize) continue;
1730
2103
  const uSize = uniforms.uSize.value;
@@ -1773,12 +2146,16 @@ function createEngine({
1773
2146
  const id = starIndexToId[pointHit.index];
1774
2147
  if (id) {
1775
2148
  const node = nodeById.get(id);
1776
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2149
+ if (node && !isNodeFiltered(node)) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
1777
2150
  }
1778
2151
  }
1779
2152
  }
1780
2153
  return void 0;
1781
2154
  }
2155
+ function onWindowBlur() {
2156
+ isMouseInWindow = false;
2157
+ edgeHoverStart = 0;
2158
+ }
1782
2159
  function onMouseDown(e) {
1783
2160
  state.lastMouseX = e.clientX;
1784
2161
  state.lastMouseY = e.clientY;
@@ -1816,6 +2193,7 @@ function createEngine({
1816
2193
  }
1817
2194
  return;
1818
2195
  }
2196
+ flyToActive = false;
1819
2197
  state.dragMode = "camera";
1820
2198
  state.isDragging = true;
1821
2199
  state.velocityX = 0;
@@ -1874,11 +2252,13 @@ function createEngine({
1874
2252
  state.lastMouseX = e.clientX;
1875
2253
  state.lastMouseY = e.clientY;
1876
2254
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2255
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2256
+ const latFactor = 1 - rotLock * rotLock;
1877
2257
  state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1878
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2258
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1879
2259
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1880
2260
  state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1881
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2261
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1882
2262
  state.lon = state.targetLon;
1883
2263
  state.lat = state.targetLat;
1884
2264
  } else {
@@ -1912,6 +2292,9 @@ function createEngine({
1912
2292
  }
1913
2293
  }
1914
2294
  function onMouseUp(e) {
2295
+ const dx = e.clientX - state.lastMouseX;
2296
+ const dy = e.clientY - state.lastMouseY;
2297
+ const movedDist = Math.sqrt(dx * dx + dy * dy);
1915
2298
  if (state.dragMode === "node") {
1916
2299
  const fullArr = getFullArrangement();
1917
2300
  handlers.onArrangementChange?.(fullArr);
@@ -1924,6 +2307,17 @@ function createEngine({
1924
2307
  state.isDragging = false;
1925
2308
  state.dragMode = "none";
1926
2309
  document.body.style.cursor = "default";
2310
+ if (movedDist < 5) {
2311
+ const hit = pick(e);
2312
+ if (hit) {
2313
+ handlers.onSelect?.(hit.node);
2314
+ constellationLayer.setFocused(hit.node.id);
2315
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2316
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2317
+ } else {
2318
+ setFocusedBook(null);
2319
+ }
2320
+ }
1927
2321
  } else {
1928
2322
  const hit = pick(e);
1929
2323
  if (hit) {
@@ -1938,6 +2332,7 @@ function createEngine({
1938
2332
  }
1939
2333
  function onWheel(e) {
1940
2334
  e.preventDefault();
2335
+ flyToActive = false;
1941
2336
  const aspect = container.clientWidth / container.clientHeight;
1942
2337
  renderer.domElement.getBoundingClientRect();
1943
2338
  const vBefore = getMouseViewVector(state.fov, aspect);
@@ -1948,6 +2343,17 @@ function createEngine({
1948
2343
  updateUniforms();
1949
2344
  const vAfter = getMouseViewVector(state.fov, aspect);
1950
2345
  const quaternion = new THREE5.Quaternion().setFromUnitVectors(vAfter, vBefore);
2346
+ const dampStartFov = 40;
2347
+ const dampEndFov = 120;
2348
+ let spinAmount = 1;
2349
+ if (state.fov > dampStartFov) {
2350
+ const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2351
+ spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2352
+ }
2353
+ if (spinAmount < 0.999) {
2354
+ const identityQuat = new THREE5.Quaternion();
2355
+ quaternion.slerp(identityQuat, 1 - spinAmount);
2356
+ }
1951
2357
  const y = Math.sin(state.lat);
1952
2358
  const r = Math.cos(state.lat);
1953
2359
  const x = r * Math.sin(state.lon);
@@ -1976,6 +2382,144 @@ function createEngine({
1976
2382
  state.targetLat = state.lat;
1977
2383
  state.targetLon = state.lon;
1978
2384
  }
2385
+ function getTouchDistance(t1, t2) {
2386
+ const dx = t1.clientX - t2.clientX;
2387
+ const dy = t1.clientY - t2.clientY;
2388
+ return Math.sqrt(dx * dx + dy * dy);
2389
+ }
2390
+ function getTouchCenter(t1, t2) {
2391
+ return {
2392
+ x: (t1.clientX + t2.clientX) / 2,
2393
+ y: (t1.clientY + t2.clientY) / 2
2394
+ };
2395
+ }
2396
+ function onTouchStart(e) {
2397
+ e.preventDefault();
2398
+ isTouchDevice = true;
2399
+ const touches = e.touches;
2400
+ state.touchCount = touches.length;
2401
+ if (touches.length === 1) {
2402
+ const touch = touches[0];
2403
+ state.touchStartTime = performance.now();
2404
+ state.touchStartX = touch.clientX;
2405
+ state.touchStartY = touch.clientY;
2406
+ state.touchMoved = false;
2407
+ state.lastMouseX = touch.clientX;
2408
+ state.lastMouseY = touch.clientY;
2409
+ flyToActive = false;
2410
+ state.dragMode = "camera";
2411
+ state.isDragging = true;
2412
+ state.velocityX = 0;
2413
+ state.velocityY = 0;
2414
+ } else if (touches.length === 2) {
2415
+ const t0 = touches[0];
2416
+ const t1 = touches[1];
2417
+ state.pinchStartDistance = getTouchDistance(t0, t1);
2418
+ state.pinchStartFov = state.fov;
2419
+ const center = getTouchCenter(t0, t1);
2420
+ state.pinchCenterX = center.x;
2421
+ state.pinchCenterY = center.y;
2422
+ state.lastMouseX = center.x;
2423
+ state.lastMouseY = center.y;
2424
+ state.touchMoved = true;
2425
+ }
2426
+ }
2427
+ function onTouchMove(e) {
2428
+ e.preventDefault();
2429
+ const touches = e.touches;
2430
+ if (touches.length === 1 && state.dragMode === "camera") {
2431
+ const touch = touches[0];
2432
+ const deltaX = touch.clientX - state.lastMouseX;
2433
+ const deltaY = touch.clientY - state.lastMouseY;
2434
+ state.lastMouseX = touch.clientX;
2435
+ state.lastMouseY = touch.clientY;
2436
+ const totalDx = touch.clientX - state.touchStartX;
2437
+ const totalDy = touch.clientY - state.touchStartY;
2438
+ if (Math.sqrt(totalDx * totalDx + totalDy * totalDy) > ENGINE_CONFIG.tapMaxDistance) {
2439
+ state.touchMoved = true;
2440
+ }
2441
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2442
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2443
+ const latFactor = 1 - rotLock * rotLock;
2444
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2445
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2446
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2447
+ state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2448
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2449
+ state.lon = state.targetLon;
2450
+ state.lat = state.targetLat;
2451
+ } else if (touches.length === 2) {
2452
+ const t0 = touches[0];
2453
+ const t1 = touches[1];
2454
+ const newDistance = getTouchDistance(t0, t1);
2455
+ const scale = newDistance / state.pinchStartDistance;
2456
+ state.fov = state.pinchStartFov / scale;
2457
+ state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2458
+ handlers.onFovChange?.(state.fov);
2459
+ const center = getTouchCenter(t0, t1);
2460
+ const deltaX = center.x - state.lastMouseX;
2461
+ const deltaY = center.y - state.lastMouseY;
2462
+ state.lastMouseX = center.x;
2463
+ state.lastMouseY = center.y;
2464
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2465
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2466
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2467
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2468
+ state.lon = state.targetLon;
2469
+ state.lat = state.targetLat;
2470
+ }
2471
+ }
2472
+ function onTouchEnd(e) {
2473
+ e.preventDefault();
2474
+ const remainingTouches = e.touches.length;
2475
+ if (remainingTouches === 0) {
2476
+ const duration = performance.now() - state.touchStartTime;
2477
+ const wasTap = !state.touchMoved && duration < ENGINE_CONFIG.tapMaxDuration;
2478
+ if (wasTap) {
2479
+ const rect = renderer.domElement.getBoundingClientRect();
2480
+ const mX = state.touchStartX - rect.left;
2481
+ const mY = state.touchStartY - rect.top;
2482
+ mouseNDC.x = mX / rect.width * 2 - 1;
2483
+ mouseNDC.y = -(mY / rect.height) * 2 + 1;
2484
+ const syntheticEvent = {
2485
+ clientX: state.touchStartX,
2486
+ clientY: state.touchStartY
2487
+ };
2488
+ const hit = pick(syntheticEvent);
2489
+ if (hit) {
2490
+ handlers.onSelect?.(hit.node);
2491
+ constellationLayer.setFocused(hit.node.id);
2492
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2493
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2494
+ } else {
2495
+ setFocusedBook(null);
2496
+ }
2497
+ }
2498
+ state.isDragging = false;
2499
+ state.dragMode = "none";
2500
+ state.touchCount = 0;
2501
+ } else if (remainingTouches === 1) {
2502
+ const touch = e.touches[0];
2503
+ state.lastMouseX = touch.clientX;
2504
+ state.lastMouseY = touch.clientY;
2505
+ state.touchCount = 1;
2506
+ state.dragMode = "camera";
2507
+ state.isDragging = true;
2508
+ state.velocityX = 0;
2509
+ state.velocityY = 0;
2510
+ }
2511
+ }
2512
+ function onTouchCancel(e) {
2513
+ e.preventDefault();
2514
+ state.isDragging = false;
2515
+ state.dragMode = "none";
2516
+ state.touchCount = 0;
2517
+ state.velocityX = 0;
2518
+ state.velocityY = 0;
2519
+ }
2520
+ function onGesturePrevent(e) {
2521
+ e.preventDefault();
2522
+ }
1979
2523
  function resize() {
1980
2524
  const w = container.clientWidth || 1;
1981
2525
  const h = container.clientHeight || 1;
@@ -1996,9 +2540,15 @@ function createEngine({
1996
2540
  el.addEventListener("mouseenter", () => {
1997
2541
  isMouseInWindow = true;
1998
2542
  });
1999
- el.addEventListener("mouseleave", () => {
2000
- isMouseInWindow = false;
2001
- });
2543
+ el.addEventListener("mouseleave", onWindowBlur);
2544
+ window.addEventListener("blur", onWindowBlur);
2545
+ el.addEventListener("touchstart", onTouchStart, { passive: false });
2546
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
2547
+ el.addEventListener("touchend", onTouchEnd, { passive: false });
2548
+ el.addEventListener("touchcancel", onTouchCancel, { passive: false });
2549
+ el.addEventListener("gesturestart", onGesturePrevent, { passive: false });
2550
+ el.addEventListener("gesturechange", onGesturePrevent, { passive: false });
2551
+ el.addEventListener("gestureend", onGesturePrevent, { passive: false });
2002
2552
  raf = requestAnimationFrame(tick);
2003
2553
  }
2004
2554
  function tick() {
@@ -2027,9 +2577,20 @@ function createEngine({
2027
2577
  if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2028
2578
  }
2029
2579
  }
2580
+ const filterTarget = currentFilter ? 1 : 0;
2581
+ filterStrength = mix(filterStrength, filterTarget, 0.1);
2582
+ if (filterStrength > 1e-3 || filterTarget > 0) {
2583
+ if (starPoints && starPoints.material) {
2584
+ const m = starPoints.material;
2585
+ if (m.uniforms.uFilterTestamentIndex) m.uniforms.uFilterTestamentIndex.value = filterTestamentIndex;
2586
+ if (m.uniforms.uFilterDivisionIndex) m.uniforms.uFilterDivisionIndex.value = filterDivisionIndex;
2587
+ if (m.uniforms.uFilterBookIndex) m.uniforms.uFilterBookIndex.value = filterBookIndex;
2588
+ if (m.uniforms.uFilterStrength) m.uniforms.uFilterStrength.value = filterStrength;
2589
+ }
2590
+ }
2030
2591
  let panX = 0;
2031
2592
  let panY = 0;
2032
- if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
2593
+ if (!state.isDragging && isMouseInWindow && !currentConfig?.editable && !isTouchDevice) {
2033
2594
  const t = ENGINE_CONFIG.edgePanThreshold;
2034
2595
  const inZoneX = mouseNDC.x < -1 + t || mouseNDC.x > 1 - t;
2035
2596
  const inZoneY = mouseNDC.y < -1 + t || mouseNDC.y > 1 - t;
@@ -2058,16 +2619,33 @@ function createEngine({
2058
2619
  } else {
2059
2620
  edgeHoverStart = 0;
2060
2621
  }
2622
+ if (flyToActive && !state.isDragging) {
2623
+ state.lon = mix(state.lon, flyToTargetLon, FLY_TO_SPEED);
2624
+ state.lat = mix(state.lat, flyToTargetLat, FLY_TO_SPEED);
2625
+ state.fov = mix(state.fov, flyToTargetFov, FLY_TO_SPEED);
2626
+ state.targetLon = state.lon;
2627
+ state.targetLat = state.lat;
2628
+ state.velocityX = 0;
2629
+ state.velocityY = 0;
2630
+ handlers.onFovChange?.(state.fov);
2631
+ if (Math.abs(state.lon - flyToTargetLon) < 1e-4 && Math.abs(state.lat - flyToTargetLat) < 1e-4 && Math.abs(state.fov - flyToTargetFov) < 0.05) {
2632
+ flyToActive = false;
2633
+ state.lon = flyToTargetLon;
2634
+ state.lat = flyToTargetLat;
2635
+ state.fov = flyToTargetFov;
2636
+ }
2637
+ }
2061
2638
  if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2062
2639
  state.lon += panX;
2063
2640
  state.lat += panY;
2064
2641
  state.targetLon = state.lon;
2065
2642
  state.targetLat = state.lat;
2066
- } else if (!state.isDragging) {
2643
+ } else if (!state.isDragging && !flyToActive) {
2067
2644
  state.lon += state.velocityX;
2068
2645
  state.lat += state.velocityY;
2069
- state.velocityX *= ENGINE_CONFIG.inertiaDamping;
2070
- state.velocityY *= ENGINE_CONFIG.inertiaDamping;
2646
+ const damping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
2647
+ state.velocityX *= damping;
2648
+ state.velocityY *= damping;
2071
2649
  if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2072
2650
  if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
2073
2651
  }
@@ -2084,13 +2662,30 @@ function createEngine({
2084
2662
  camera.updateMatrixWorld();
2085
2663
  camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
2086
2664
  updateUniforms();
2087
- constellationLayer.update(state.fov, currentConfig?.showConstellationArt ?? false);
2665
+ const nowSec = now / 1e3;
2666
+ const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2667
+ lastTickTime = nowSec;
2668
+ linesFader.target = currentConfig?.showConstellationLines ?? false;
2669
+ linesFader.update(dt);
2670
+ artFader.target = currentConfig?.showConstellationArt ?? false;
2671
+ artFader.update(dt);
2672
+ constellationLayer.update(state.fov, artFader.eased > 0.01);
2673
+ if (artFader.eased < 1) {
2674
+ constellationLayer.setGlobalOpacity?.(artFader.eased);
2675
+ }
2088
2676
  backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2089
2677
  if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2090
2678
  const DIVISION_THRESHOLD = 60;
2091
2679
  const showDivisions = state.fov > DIVISION_THRESHOLD;
2092
2680
  if (constellationLines) {
2093
- constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2681
+ constellationLines.visible = linesFader.eased > 0.01;
2682
+ if (constellationLines.visible && constellationLines.material) {
2683
+ const mat = constellationLines.material;
2684
+ if (mat.uniforms?.color) {
2685
+ mat.uniforms.color.value.setHex(11193599);
2686
+ mat.opacity = linesFader.eased;
2687
+ }
2688
+ }
2094
2689
  }
2095
2690
  if (boundaryLines) {
2096
2691
  boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
@@ -2111,8 +2706,7 @@ function createEngine({
2111
2706
  const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2112
2707
  const showChapterLabels = currentConfig?.showChapterLabels === true;
2113
2708
  const showGroupLabels = currentConfig?.showGroupLabels === true;
2114
- const showBooks = state.fov < 120;
2115
- const showChapters = state.fov < 70;
2709
+ const showChapters = state.fov < 45;
2116
2710
  for (const item of dynamicLabels) {
2117
2711
  const uniforms = item.obj.material.uniforms;
2118
2712
  const level = item.node.level;
@@ -2133,11 +2727,6 @@ function createEngine({
2133
2727
  item.obj.visible = uniforms.uAlpha.value > 0.01;
2134
2728
  continue;
2135
2729
  }
2136
- if (level === 2 && !showBooks && item.node.id !== state.draggedNodeId) {
2137
- uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2138
- item.obj.visible = uniforms.uAlpha.value > 0.01;
2139
- continue;
2140
- }
2141
2730
  if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2142
2731
  uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2143
2732
  item.obj.visible = uniforms.uAlpha.value > 0.01;
@@ -2170,8 +2759,8 @@ function createEngine({
2170
2759
  const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2171
2760
  if (l.level === 1) {
2172
2761
  let rot = 0;
2173
- const blend = globalUniforms.uBlend.value;
2174
- if (blend > 0.5) {
2762
+ const isWideAngle = currentProjection.id !== "perspective";
2763
+ if (isWideAngle) {
2175
2764
  const dx = l.sX - screenW / 2;
2176
2765
  const dy = l.sY - screenH / 2;
2177
2766
  rot = Math.atan2(-dy, -dx) - Math.PI / 2;
@@ -2179,7 +2768,7 @@ function createEngine({
2179
2768
  l.uniforms.uAngle.value = THREE5.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2180
2769
  }
2181
2770
  if (l.level === 2) {
2182
- if (showBooks || isSpecial) {
2771
+ {
2183
2772
  target2 = 1;
2184
2773
  occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2185
2774
  }
@@ -2201,6 +2790,17 @@ function createEngine({
2201
2790
  }
2202
2791
  }
2203
2792
  }
2793
+ if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2794
+ const node = l.item.node;
2795
+ if (node.level === 3) {
2796
+ target2 = 0;
2797
+ } else if (node.level === 2 || node.level === 2.5) {
2798
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2799
+ if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2800
+ target2 = 0;
2801
+ }
2802
+ }
2803
+ }
2204
2804
  l.uniforms.uAlpha.value = THREE5.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2205
2805
  l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
2206
2806
  }
@@ -2215,6 +2815,15 @@ function createEngine({
2215
2815
  window.removeEventListener("mousemove", onMouseMove);
2216
2816
  window.removeEventListener("mouseup", onMouseUp);
2217
2817
  el.removeEventListener("wheel", onWheel);
2818
+ el.removeEventListener("mouseleave", onWindowBlur);
2819
+ window.removeEventListener("blur", onWindowBlur);
2820
+ el.removeEventListener("touchstart", onTouchStart);
2821
+ el.removeEventListener("touchmove", onTouchMove);
2822
+ el.removeEventListener("touchend", onTouchEnd);
2823
+ el.removeEventListener("touchcancel", onTouchCancel);
2824
+ el.removeEventListener("gesturestart", onGesturePrevent);
2825
+ el.removeEventListener("gesturechange", onGesturePrevent);
2826
+ el.removeEventListener("gestureend", onGesturePrevent);
2218
2827
  }
2219
2828
  function dispose() {
2220
2829
  stop();
@@ -2235,7 +2844,30 @@ function createEngine({
2235
2844
  function setOrderRevealEnabled(enabled) {
2236
2845
  orderRevealEnabled = enabled;
2237
2846
  }
2238
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled };
2847
+ function flyTo(nodeId, targetFov) {
2848
+ const node = nodeById.get(nodeId);
2849
+ if (!node) return;
2850
+ const pos = getPosition(node).normalize();
2851
+ flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2852
+ flyToTargetLon = Math.atan2(pos.x, -pos.z);
2853
+ flyToTargetFov = targetFov ?? ENGINE_CONFIG.minFov;
2854
+ flyToActive = true;
2855
+ state.velocityX = 0;
2856
+ state.velocityY = 0;
2857
+ }
2858
+ function setHierarchyFilter(filter) {
2859
+ currentFilter = filter;
2860
+ if (filter) {
2861
+ filterTestamentIndex = filter.testament && testamentToIndex.has(filter.testament) ? testamentToIndex.get(filter.testament) : -1;
2862
+ filterDivisionIndex = filter.division && divisionToIndex.has(filter.division) ? divisionToIndex.get(filter.division) : -1;
2863
+ filterBookIndex = filter.bookKey && bookIdToIndex.has(`B:${filter.bookKey}`) ? bookIdToIndex.get(`B:${filter.bookKey}`) : -1;
2864
+ } else {
2865
+ filterTestamentIndex = -1;
2866
+ filterDivisionIndex = -1;
2867
+ filterBookIndex = -1;
2868
+ }
2869
+ }
2870
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
2239
2871
  }
2240
2872
  var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
2241
2873
  var init_createEngine = __esm({
@@ -2243,20 +2875,29 @@ var init_createEngine = __esm({
2243
2875
  init_layout();
2244
2876
  init_materials();
2245
2877
  init_ConstellationArtworkLayer();
2878
+ init_projections();
2879
+ init_fader();
2246
2880
  ENGINE_CONFIG = {
2247
- minFov: 10,
2248
- maxFov: 165,
2249
- defaultFov: 80,
2881
+ minFov: 1,
2882
+ maxFov: 135,
2883
+ defaultFov: 50,
2250
2884
  dragSpeed: 125e-5,
2251
2885
  inertiaDamping: 0.92,
2252
- blendStart: 60,
2253
- blendEnd: 165,
2254
- zenithStartFov: 110,
2255
- zenithStrength: 0.02,
2886
+ blendStart: 35,
2887
+ blendEnd: 83,
2888
+ zenithStartFov: 75,
2889
+ zenithStrength: 0.15,
2256
2890
  horizonLockStrength: 0.05,
2257
2891
  edgePanThreshold: 0.15,
2258
2892
  edgePanMaxSpeed: 0.02,
2259
- edgePanDelay: 250
2893
+ edgePanDelay: 250,
2894
+ // Touch-specific
2895
+ touchInertiaDamping: 0.85,
2896
+ // Snappier than mouse (0.92)
2897
+ tapMaxDuration: 300,
2898
+ // ms
2899
+ tapMaxDistance: 10
2900
+ // px
2260
2901
  };
2261
2902
  ORDER_REVEAL_CONFIG = {
2262
2903
  globalDim: 0.85,
@@ -2274,7 +2915,10 @@ var StarMap = forwardRef(
2274
2915
  getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2275
2916
  setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2276
2917
  setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2277
- setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled)
2918
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled),
2919
+ setHierarchyFilter: (filter) => engineRef.current?.setHierarchyFilter?.(filter),
2920
+ flyTo: (nodeId, targetFov) => engineRef.current?.flyTo?.(nodeId, targetFov),
2921
+ setProjection: (id) => engineRef.current?.setProjection?.(id)
2278
2922
  }));
2279
2923
  useEffect(() => {
2280
2924
  let disposed = false;
@@ -31551,6 +32195,9 @@ function generateArrangement(bible, options = {}) {
31551
32195
  return arrangement;
31552
32196
  }
31553
32197
 
31554
- export { StarMap, bibleToSceneModel, defaultGenerateOptions, default_stars_default as defaultStars, generateArrangement };
32198
+ // src/index.ts
32199
+ init_projections();
32200
+
32201
+ export { PROJECTIONS, StarMap, bibleToSceneModel, defaultGenerateOptions, default_stars_default as defaultStars, generateArrangement };
31555
32202
  //# sourceMappingURL=index.js.map
31556
32203
  //# sourceMappingURL=index.js.map