@project-skymap/library 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var THREE4 = require('three');
3
+ var THREE5 = require('three');
4
4
  var react = require('react');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
 
@@ -22,7 +22,7 @@ function _interopNamespace(e) {
22
22
  return Object.freeze(n);
23
23
  }
24
24
 
25
- var THREE4__namespace = /*#__PURE__*/_interopNamespace(THREE4);
25
+ var THREE5__namespace = /*#__PURE__*/_interopNamespace(THREE5);
26
26
 
27
27
  var __defProp = Object.defineProperty;
28
28
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -151,14 +151,14 @@ var init_constellations = __esm({
151
151
  });
152
152
  function lookAt(point, target, up) {
153
153
  const zAxis = target.clone().normalize();
154
- let xAxis = new THREE4__namespace.Vector3().crossVectors(up, zAxis);
154
+ let xAxis = new THREE5__namespace.Vector3().crossVectors(up, zAxis);
155
155
  if (xAxis.lengthSq() < 1e-4) {
156
- xAxis = new THREE4__namespace.Vector3().crossVectors(new THREE4__namespace.Vector3(1, 0, 0), zAxis);
156
+ xAxis = new THREE5__namespace.Vector3().crossVectors(new THREE5__namespace.Vector3(1, 0, 0), zAxis);
157
157
  }
158
158
  xAxis.normalize();
159
- const yAxis = new THREE4__namespace.Vector3().crossVectors(zAxis, xAxis).normalize();
160
- const m = new THREE4__namespace.Matrix4().makeBasis(xAxis, yAxis, zAxis);
161
- const v = new THREE4__namespace.Vector3(point.x, point.y, point.z);
159
+ const yAxis = new THREE5__namespace.Vector3().crossVectors(zAxis, xAxis).normalize();
160
+ const m = new THREE5__namespace.Matrix4().makeBasis(xAxis, yAxis, zAxis);
161
+ const v = new THREE5__namespace.Vector3(point.x, point.y, point.z);
162
162
  v.applyMatrix4(m);
163
163
  v.add(target);
164
164
  return { x: v.x, y: v.y, z: v.z };
@@ -239,7 +239,7 @@ function computeLayoutPositions(model, layout) {
239
239
  const radiusAtY = Math.sqrt(1 - y * y);
240
240
  const x = Math.cos(midAngle) * radiusAtY;
241
241
  const z = Math.sin(midAngle) * radiusAtY;
242
- const labelPos = new THREE4__namespace.Vector3(x, y, z).multiplyScalar(radius);
242
+ const labelPos = new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
243
243
  uDivision.meta.x = labelPos.x;
244
244
  uDivision.meta.y = labelPos.y;
245
245
  uDivision.meta.z = labelPos.z;
@@ -255,7 +255,7 @@ function computeLayoutPositions(model, layout) {
255
255
  const theta = startAngle + t * angleSpan;
256
256
  const x = Math.cos(theta) * radiusAtY;
257
257
  const z = Math.sin(theta) * radiusAtY;
258
- const bookPos = new THREE4__namespace.Vector3(x, y, z).multiplyScalar(radius);
258
+ const bookPos = new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
259
259
  const labelPos = bookPos.clone();
260
260
  labelPos.y += radius * 0.025;
261
261
  labelPos.setLength(radius);
@@ -266,7 +266,7 @@ function computeLayoutPositions(model, layout) {
266
266
  if (chapters.length > 0) {
267
267
  const territoryRadius = radius * 2 / Math.sqrt(books.length * 2) * 0.7;
268
268
  const localPoints = getConstellationLayout(bookKey, chapters.length, territoryRadius);
269
- const up = new THREE4__namespace.Vector3(0, 1, 0);
269
+ const up = new THREE5__namespace.Vector3(0, 1, 0);
270
270
  chapters.forEach((chap, idx) => {
271
271
  const uChap = updatedNodeMap.get(chap.id);
272
272
  const lp = localPoints[idx];
@@ -285,10 +285,10 @@ function computeLayoutPositions(model, layout) {
285
285
  testaments.forEach((t) => {
286
286
  const children = childrenMap.get(t.id) ?? [];
287
287
  if (children.length === 0) return;
288
- const centroid = new THREE4__namespace.Vector3();
288
+ const centroid = new THREE5__namespace.Vector3();
289
289
  children.forEach((c) => {
290
290
  const u = updatedNodeMap.get(c.id);
291
- centroid.add(new THREE4__namespace.Vector3(u.meta.x, u.meta.y, u.meta.z));
291
+ centroid.add(new THREE5__namespace.Vector3(u.meta.x, u.meta.y, u.meta.z));
292
292
  });
293
293
  centroid.divideScalar(children.length);
294
294
  if (centroid.length() > 0.1) {
@@ -341,45 +341,67 @@ 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);
349
+ float k;
350
+
351
+ // Radial Clipping: Push clipped points off-screen in their natural direction
352
+ // to prevent lines "darting" across the center.
353
+ vec2 escapeDir = (length(dir.xy) > 0.0001) ? normalize(dir.xy) : vec2(1.0, 1.0);
354
+ vec2 escapePos = escapeDir * 10000.0;
355
+
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
+
352
376
  vec2 projected = vec2(k * dir.x, k * dir.y);
353
377
  projected *= uScale;
354
378
  projected.x /= uAspect;
355
- float zMetric = -1.0 + (dist / 2000.0);
356
- // Clip backward facing points in fisheye mode
357
- if (uBlend > 0.5 && dir.z > 0.4) return vec4(10.0, 10.0, 10.0, 1.0);
358
- // Clip very close points in linear mode
359
- if (uBlend < 0.1 && dir.z > -0.1) return vec4(10.0, 10.0, 10.0, 1.0);
379
+ float zMetric = -1.0 + (dist / 15000.0);
380
+
360
381
  return vec4(projected, zMetric, 1.0);
361
382
  }
362
383
  `;
363
384
  MASK_CHUNK = `
364
385
  uniform float uAspect;
365
386
  uniform float uBlend;
387
+ uniform int uProjectionType;
366
388
  varying vec2 vScreenPos;
367
389
  float getMaskAlpha() {
368
- 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.
369
393
  vec2 p = vScreenPos;
370
394
  p.x *= uAspect;
371
395
  float dist = length(p);
372
- float t = smoothstep(0.75, 1.0, uBlend);
373
- float currentRadius = mix(2.5, 1.0, t);
374
- float edgeSoftness = mix(0.5, 0.02, t);
375
- 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);
376
398
  }
377
399
  `;
378
400
  }
379
401
  });
380
402
  function createSmartMaterial(params) {
381
403
  const uniforms = { ...globalUniforms, ...params.uniforms };
382
- return new THREE4__namespace.ShaderMaterial({
404
+ return new THREE5__namespace.ShaderMaterial({
383
405
  uniforms,
384
406
  vertexShader: `
385
407
  ${BLEND_CHUNK}
@@ -393,8 +415,8 @@ function createSmartMaterial(params) {
393
415
  transparent: params.transparent || false,
394
416
  depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
395
417
  depthTest: params.depthTest !== void 0 ? params.depthTest : true,
396
- side: params.side || THREE4__namespace.FrontSide,
397
- blending: params.blending || THREE4__namespace.NormalBlending
418
+ side: params.side || THREE5__namespace.FrontSide,
419
+ blending: params.blending || THREE5__namespace.NormalBlending
398
420
  });
399
421
  }
400
422
  var globalUniforms;
@@ -404,7 +426,412 @@ var init_materials = __esm({
404
426
  globalUniforms = {
405
427
  uScale: { value: 1 },
406
428
  uAspect: { value: 1 },
407
- uBlend: { value: 0 }
429
+ uBlend: { value: 0 },
430
+ uProjectionType: { value: 2 },
431
+ // 0=perspective, 1=stereographic, 2=blended
432
+ uTime: { value: 0 },
433
+ // Atmosphere Settings
434
+ uAtmGlow: { value: 1 },
435
+ uAtmDark: { value: 0.6 },
436
+ uAtmExtinction: { value: 4 },
437
+ uAtmTwinkle: { value: 0.8 },
438
+ uColorHorizon: { value: new THREE5__namespace.Color(3825292) },
439
+ uColorZenith: { value: new THREE5__namespace.Color(132104) }
440
+ };
441
+ }
442
+ });
443
+ var ConstellationArtworkLayer;
444
+ var init_ConstellationArtworkLayer = __esm({
445
+ "src/engine/ConstellationArtworkLayer.ts"() {
446
+ init_materials();
447
+ ConstellationArtworkLayer = class {
448
+ root;
449
+ items = [];
450
+ textureLoader = new THREE5__namespace.TextureLoader();
451
+ hoveredId = null;
452
+ focusedId = null;
453
+ constructor(root) {
454
+ this.root = new THREE5__namespace.Group();
455
+ this.root.renderOrder = -1;
456
+ root.add(this.root);
457
+ }
458
+ getItems() {
459
+ return this.items;
460
+ }
461
+ setPosition(id, pos) {
462
+ const item = this.items.find((i) => i.config.id === id);
463
+ if (item) {
464
+ item.mesh.position.copy(pos);
465
+ }
466
+ }
467
+ load(config, getPosition) {
468
+ this.clear();
469
+ const basePath = config.atlasBasePath.replace(/\/$/, "");
470
+ config.constellations.forEach((c) => {
471
+ let center = new THREE5__namespace.Vector3();
472
+ let valid = false;
473
+ let radius = 2e3;
474
+ const arrPos = getPosition(c.id);
475
+ if (arrPos) {
476
+ center.copy(arrPos);
477
+ valid = true;
478
+ if (c.anchors.length > 0) {
479
+ const points = [];
480
+ for (const anchorId of c.anchors) {
481
+ const p = getPosition(anchorId);
482
+ if (p) points.push(p);
483
+ }
484
+ if (points.length > 0) {
485
+ radius = points[0].length();
486
+ }
487
+ }
488
+ } else if (c.center) {
489
+ center.set(c.center[0], c.center[1], c.center[2]);
490
+ valid = true;
491
+ } else if (c.anchors.length > 0) {
492
+ const points = [];
493
+ for (const anchorId of c.anchors) {
494
+ const p = getPosition(anchorId);
495
+ if (p) points.push(p);
496
+ }
497
+ if (points.length > 0) {
498
+ for (const p of points) center.add(p);
499
+ center.divideScalar(points.length);
500
+ const len = center.length();
501
+ if (len > 1e-3) {
502
+ radius = points[0].length();
503
+ center.normalize().multiplyScalar(radius);
504
+ }
505
+ valid = true;
506
+ }
507
+ }
508
+ if (!valid) return;
509
+ const normal = center.clone().normalize().negate();
510
+ const upVec = center.clone().normalize();
511
+ let right = new THREE5__namespace.Vector3(1, 0, 0);
512
+ if (c.anchors.length >= 2) {
513
+ const p0 = getPosition(c.anchors[0]);
514
+ const p1 = getPosition(c.anchors[1]);
515
+ if (p0 && p1) {
516
+ const diff = new THREE5__namespace.Vector3().subVectors(p1, p0);
517
+ right.copy(diff).sub(upVec.clone().multiplyScalar(diff.dot(upVec))).normalize();
518
+ }
519
+ } else {
520
+ if (Math.abs(upVec.y) > 0.9) right.set(1, 0, 0).cross(upVec).normalize();
521
+ else right.set(0, 1, 0).cross(upVec).normalize();
522
+ }
523
+ const top = new THREE5__namespace.Vector3().crossVectors(upVec, right).normalize();
524
+ right.crossVectors(top, upVec).normalize();
525
+ new THREE5__namespace.Matrix4().makeBasis(right, top, normal);
526
+ const geometry = new THREE5__namespace.PlaneGeometry(1, 1);
527
+ let size = c.radius;
528
+ if (size <= 1) size *= radius;
529
+ size *= 2;
530
+ const texPath = `${basePath}/${c.image}`;
531
+ let blending = THREE5__namespace.NormalBlending;
532
+ if (c.blend === "additive") blending = THREE5__namespace.AdditiveBlending;
533
+ const material = createSmartMaterial({
534
+ uniforms: {
535
+ uMap: { value: this.textureLoader.load(texPath) },
536
+ // Placeholder, updated below
537
+ uOpacity: { value: c.opacity },
538
+ uSize: { value: size },
539
+ uImgRotation: { value: THREE5__namespace.MathUtils.degToRad(c.rotationDeg) },
540
+ uImgAspect: { value: c.aspectRatio ?? 1 }
541
+ // uScale, uAspect (screen) are injected by createSmartMaterial/globalUniforms
542
+ },
543
+ vertexShaderBody: `
544
+ uniform float uSize;
545
+ uniform float uImgRotation;
546
+ uniform float uImgAspect;
547
+
548
+ varying vec2 vUv;
549
+
550
+ void main() {
551
+ vUv = uv;
552
+
553
+ // 1. Project Center Point (Proven Method)
554
+ vec4 mvCenter = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
555
+ vec4 clipCenter = smartProject(mvCenter);
556
+
557
+ // 2. Project "Up" Point (World Zenith)
558
+ // Transform World Up (0,1,0) to View Space
559
+ vec3 viewUpDir = mat3(viewMatrix) * vec3(0.0, 1.0, 0.0);
560
+ // Offset center by a significant amount (1000.0) to ensure screen delta
561
+ vec4 mvUp = mvCenter + vec4(viewUpDir * 1000.0, 0.0);
562
+ vec4 clipUp = smartProject(mvUp);
563
+
564
+ // 3. Calculate Horizon Angle
565
+ vec2 screenCenter = clipCenter.xy / clipCenter.w;
566
+ vec2 screenUp = clipUp.xy / clipUp.w;
567
+ vec2 screenDelta = screenUp - screenCenter;
568
+
569
+ float horizonAngle = 0.0;
570
+ if (length(screenDelta) > 0.001) {
571
+ vec2 screenDir = normalize(screenDelta);
572
+ horizonAngle = atan(screenDir.y, screenDir.x) - 1.5708; // -90 deg
573
+ }
574
+
575
+ // 4. Combine with User Rotation
576
+ float finalAngle = uImgRotation + horizonAngle;
577
+
578
+ // 5. Billboard Offset
579
+ vec2 offset = position.xy;
580
+
581
+ float cr = cos(finalAngle);
582
+ float sr = sin(finalAngle);
583
+ vec2 rotated = vec2(
584
+ offset.x * cr - offset.y * sr,
585
+ offset.x * sr + offset.y * cr
586
+ );
587
+
588
+ rotated.x *= uImgAspect;
589
+
590
+ float dist = length(mvCenter.xyz);
591
+ float scale = (uSize / dist) * uScale;
592
+
593
+ rotated *= scale;
594
+ rotated.x /= uAspect;
595
+
596
+ gl_Position = clipCenter;
597
+ gl_Position.xy += rotated * clipCenter.w;
598
+
599
+ vScreenPos = gl_Position.xy / gl_Position.w;
600
+ }
601
+ `,
602
+ fragmentShader: `
603
+ uniform sampler2D uMap;
604
+ uniform float uOpacity;
605
+ varying vec2 vUv;
606
+ void main() {
607
+ float mask = getMaskAlpha();
608
+ if (mask < 0.01) discard;
609
+ vec4 tex = texture2D(uMap, vUv);
610
+ gl_FragColor = vec4(tex.rgb, tex.a * uOpacity * mask);
611
+ }
612
+ `,
613
+ transparent: true,
614
+ depthWrite: false,
615
+ depthTest: true,
616
+ blending,
617
+ side: THREE5__namespace.DoubleSide
618
+ });
619
+ material.uniforms.uMap.value = this.textureLoader.load(texPath, (tex) => {
620
+ if (c.aspectRatio === void 0 && tex.image.width && tex.image.height) {
621
+ const natAspect = tex.image.width / tex.image.height;
622
+ material.uniforms.uImgAspect.value = natAspect;
623
+ }
624
+ });
625
+ if (c.zBias) {
626
+ material.polygonOffset = true;
627
+ material.polygonOffsetFactor = -c.zBias;
628
+ }
629
+ const mesh = new THREE5__namespace.Mesh(geometry, material);
630
+ mesh.frustumCulled = false;
631
+ mesh.userData = { id: c.id, type: "constellation" };
632
+ mesh.position.copy(center);
633
+ this.root.add(mesh);
634
+ this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
635
+ });
636
+ }
637
+ _globalOpacity = 1;
638
+ setGlobalOpacity(v) {
639
+ this._globalOpacity = v;
640
+ }
641
+ update(fov, showArt) {
642
+ this.root.visible = showArt;
643
+ if (!showArt) return;
644
+ for (const item of this.items) {
645
+ const { fade } = item.config;
646
+ let opacity = fade.maxOpacity;
647
+ if (fov >= fade.zoomInStart) {
648
+ opacity = fade.maxOpacity;
649
+ } else if (fov <= fade.zoomInEnd) {
650
+ opacity = fade.minOpacity;
651
+ } else {
652
+ const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
653
+ opacity = THREE5__namespace.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
654
+ }
655
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
656
+ item.material.uniforms.uOpacity.value = opacity;
657
+ }
658
+ }
659
+ setHovered(id) {
660
+ this.hoveredId = id;
661
+ }
662
+ setFocused(id) {
663
+ this.focusedId = id;
664
+ }
665
+ dispose() {
666
+ this.clear();
667
+ this.root.removeFromParent();
668
+ }
669
+ clear() {
670
+ this.items.forEach((i) => {
671
+ this.root.remove(i.mesh);
672
+ i.material.dispose();
673
+ i.mesh.geometry.dispose();
674
+ });
675
+ this.items = [];
676
+ }
677
+ };
678
+ }
679
+ });
680
+
681
+ // src/engine/projections.ts
682
+ var PerspectiveProjection, StereographicProjection, BlendedProjection; exports.PROJECTIONS = void 0;
683
+ var init_projections = __esm({
684
+ "src/engine/projections.ts"() {
685
+ PerspectiveProjection = class {
686
+ id = "perspective";
687
+ label = "Perspective";
688
+ maxFov = 160;
689
+ glslProjectionType = 0;
690
+ forward(dir) {
691
+ if (dir.z > -0.1) return null;
692
+ const k = 1 / Math.max(0.01, -dir.z);
693
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
694
+ }
695
+ inverse(uvX, uvY, fovRad) {
696
+ const halfHeight = Math.tan(fovRad / 2);
697
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
698
+ const theta = Math.atan(r * halfHeight);
699
+ const phi = Math.atan2(uvY, uvX);
700
+ const sinT = Math.sin(theta);
701
+ return {
702
+ x: sinT * Math.cos(phi),
703
+ y: sinT * Math.sin(phi),
704
+ z: -Math.cos(theta)
705
+ };
706
+ }
707
+ getScale(fovRad) {
708
+ return 1 / Math.tan(fovRad / 2);
709
+ }
710
+ isClipped(dirZ) {
711
+ return dirZ > -0.1;
712
+ }
713
+ };
714
+ StereographicProjection = class {
715
+ id = "stereographic";
716
+ label = "Stereographic";
717
+ maxFov = 360;
718
+ glslProjectionType = 1;
719
+ forward(dir) {
720
+ if (dir.z > 0.4) return null;
721
+ const k = 2 / (1 - dir.z);
722
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
723
+ }
724
+ inverse(uvX, uvY, fovRad) {
725
+ const halfHeight = 2 * Math.tan(fovRad / 4);
726
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
727
+ const theta = 2 * Math.atan(r * halfHeight / 2);
728
+ const phi = Math.atan2(uvY, uvX);
729
+ const sinT = Math.sin(theta);
730
+ return {
731
+ x: sinT * Math.cos(phi),
732
+ y: sinT * Math.sin(phi),
733
+ z: -Math.cos(theta)
734
+ };
735
+ }
736
+ getScale(fovRad) {
737
+ return 1 / (2 * Math.tan(fovRad / 4));
738
+ }
739
+ isClipped(dirZ) {
740
+ return dirZ > 0.4;
741
+ }
742
+ };
743
+ BlendedProjection = class {
744
+ id = "blended";
745
+ label = "Blended (Auto)";
746
+ maxFov = 165;
747
+ glslProjectionType = 2;
748
+ /** FOV thresholds for blend transition (degrees) */
749
+ blendStart = 40;
750
+ blendEnd = 100;
751
+ /** Current blend factor, updated via setFov() */
752
+ blend = 0;
753
+ /** Call this each frame / when FOV changes so forward/inverse stay in sync */
754
+ setFov(fovDeg) {
755
+ if (fovDeg <= this.blendStart) {
756
+ this.blend = 0;
757
+ return;
758
+ }
759
+ if (fovDeg >= this.blendEnd) {
760
+ this.blend = 1;
761
+ return;
762
+ }
763
+ const t = (fovDeg - this.blendStart) / (this.blendEnd - this.blendStart);
764
+ this.blend = t * t * (3 - 2 * t);
765
+ }
766
+ getBlend() {
767
+ return this.blend;
768
+ }
769
+ forward(dir) {
770
+ if (this.blend > 0.5 && dir.z > 0.4) return null;
771
+ if (this.blend < 0.1 && dir.z > -0.1) return null;
772
+ const kLinear = 1 / Math.max(0.01, -dir.z);
773
+ const kStereo = 2 / (1 - dir.z);
774
+ const k = kLinear * (1 - this.blend) + kStereo * this.blend;
775
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
776
+ }
777
+ inverse(uvX, uvY, fovRad) {
778
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
779
+ const halfHeightLin = Math.tan(fovRad / 2);
780
+ const thetaLin = Math.atan(r * halfHeightLin);
781
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
782
+ const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
783
+ const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
784
+ const phi = Math.atan2(uvY, uvX);
785
+ const sinT = Math.sin(theta);
786
+ return {
787
+ x: sinT * Math.cos(phi),
788
+ y: sinT * Math.sin(phi),
789
+ z: -Math.cos(theta)
790
+ };
791
+ }
792
+ getScale(fovRad) {
793
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
794
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
795
+ return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
796
+ }
797
+ isClipped(dirZ) {
798
+ if (this.blend > 0.5) return dirZ > 0.4;
799
+ if (this.blend < 0.1) return dirZ > -0.1;
800
+ return false;
801
+ }
802
+ };
803
+ exports.PROJECTIONS = {
804
+ perspective: () => new PerspectiveProjection(),
805
+ stereographic: () => new StereographicProjection(),
806
+ blended: () => new BlendedProjection()
807
+ };
808
+ }
809
+ });
810
+
811
+ // src/engine/fader.ts
812
+ var Fader;
813
+ var init_fader = __esm({
814
+ "src/engine/fader.ts"() {
815
+ Fader = class {
816
+ target = false;
817
+ value = 0;
818
+ duration;
819
+ constructor(duration = 0.3) {
820
+ this.duration = duration;
821
+ }
822
+ update(dt) {
823
+ const goal = this.target ? 1 : 0;
824
+ if (this.value === goal) return;
825
+ const speed = 1 / this.duration;
826
+ const step = speed * dt;
827
+ const diff = goal - this.value;
828
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
829
+ }
830
+ /** Smoothstep-eased value for perceptually smooth transitions */
831
+ get eased() {
832
+ const v = this.value;
833
+ return v * v * (3 - 2 * v);
834
+ }
408
835
  };
409
836
  }
410
837
  });
@@ -418,17 +845,49 @@ function createEngine({
418
845
  container,
419
846
  onSelect,
420
847
  onHover,
421
- onArrangementChange
848
+ onArrangementChange,
849
+ onFovChange
422
850
  }) {
423
- const renderer = new THREE4__namespace.WebGLRenderer({ antialias: true, alpha: false });
851
+ let hoveredBookId = null;
852
+ let focusedBookId = null;
853
+ let orderRevealEnabled = true;
854
+ let activeBookIndex = -1;
855
+ let orderRevealStrength = 0;
856
+ let flyToActive = false;
857
+ let flyToTargetLon = 0;
858
+ let flyToTargetLat = 0;
859
+ let flyToTargetFov = ENGINE_CONFIG.minFov;
860
+ const FLY_TO_SPEED = 0.04;
861
+ let currentFilter = null;
862
+ let filterStrength = 0;
863
+ let filterTestamentIndex = -1;
864
+ let filterDivisionIndex = -1;
865
+ let filterBookIndex = -1;
866
+ const hoverCooldowns = /* @__PURE__ */ new Map();
867
+ const COOLDOWN_MS = 2e3;
868
+ const bookIdToIndex = /* @__PURE__ */ new Map();
869
+ const testamentToIndex = /* @__PURE__ */ new Map();
870
+ const divisionToIndex = /* @__PURE__ */ new Map();
871
+ const renderer = new THREE5__namespace.WebGLRenderer({ antialias: true, alpha: false });
424
872
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
425
873
  renderer.setSize(container.clientWidth, container.clientHeight);
426
874
  container.appendChild(renderer.domElement);
427
- const scene = new THREE4__namespace.Scene();
428
- scene.background = new THREE4__namespace.Color(0);
429
- const camera = new THREE4__namespace.PerspectiveCamera(60, 1, 0.1, 3e3);
875
+ const scene = new THREE5__namespace.Scene();
876
+ scene.background = new THREE5__namespace.Color(0);
877
+ const camera = new THREE5__namespace.PerspectiveCamera(60, 1, 0.1, 1e4);
430
878
  camera.position.set(0, 0, 0);
431
879
  camera.up.set(0, 1, 0);
880
+ function setHoveredBook(id) {
881
+ if (id === hoveredBookId) return;
882
+ const now = performance.now();
883
+ if (hoveredBookId) {
884
+ hoverCooldowns.set(hoveredBookId, now);
885
+ }
886
+ if (id) {
887
+ hoverCooldowns.get(id) || 0;
888
+ }
889
+ hoveredBookId = id;
890
+ }
432
891
  let running = false;
433
892
  let raf = 0;
434
893
  const state = {
@@ -449,247 +908,312 @@ function createEngine({
449
908
  draggedGroup: null,
450
909
  tempArrangement: {}
451
910
  };
452
- const mouseNDC = new THREE4__namespace.Vector2();
911
+ const mouseNDC = new THREE5__namespace.Vector2();
453
912
  let isMouseInWindow = false;
454
- let handlers = { onSelect, onHover, onArrangementChange };
913
+ let edgeHoverStart = 0;
914
+ let handlers = { onSelect, onHover, onArrangementChange, onFovChange };
455
915
  let currentConfig;
916
+ const constellationLayer = new ConstellationArtworkLayer(scene);
456
917
  function mix(a, b, t) {
457
918
  return a * (1 - t) + b * t;
458
919
  }
459
- function getBlendFactor(fov) {
460
- if (fov <= ENGINE_CONFIG.blendStart) return 0;
461
- if (fov >= ENGINE_CONFIG.blendEnd) return 1;
462
- let t = (fov - ENGINE_CONFIG.blendStart) / (ENGINE_CONFIG.blendEnd - ENGINE_CONFIG.blendStart);
463
- return t * t * (3 - 2 * t);
920
+ let currentProjection = exports.PROJECTIONS.blended();
921
+ function syncProjectionState() {
922
+ if (currentProjection instanceof BlendedProjection) {
923
+ currentProjection.setFov(state.fov);
924
+ globalUniforms.uBlend.value = currentProjection.getBlend();
925
+ }
926
+ globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
464
927
  }
465
928
  function updateUniforms() {
466
- const blend = getBlendFactor(state.fov);
467
- globalUniforms.uBlend.value = blend;
929
+ syncProjectionState();
468
930
  const fovRad = state.fov * Math.PI / 180;
469
- const scaleLinear = 1 / Math.tan(fovRad / 2);
470
- const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
471
- globalUniforms.uScale.value = mix(scaleLinear, scaleStereo, blend);
472
- globalUniforms.uAspect.value = camera.aspect;
931
+ let scale = currentProjection.getScale(fovRad);
932
+ const aspect = camera.aspect;
933
+ if (currentConfig?.fitProjection) {
934
+ if (aspect > 1) {
935
+ scale /= aspect;
936
+ }
937
+ }
938
+ globalUniforms.uScale.value = scale;
939
+ globalUniforms.uAspect.value = aspect;
473
940
  camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
474
941
  camera.updateProjectionMatrix();
475
942
  }
476
943
  function getMouseViewVector(fovDeg, aspectRatio) {
477
- const blend = getBlendFactor(fovDeg);
944
+ syncProjectionState();
478
945
  const fovRad = fovDeg * Math.PI / 180;
479
946
  const uvX = mouseNDC.x * aspectRatio;
480
947
  const uvY = mouseNDC.y;
481
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
482
- const halfHeightLinear = Math.tan(fovRad / 2);
483
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
484
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
485
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
486
- const theta = mix(theta_lin, theta_str, blend);
487
- const phi = Math.atan2(uvY, uvX);
488
- const sinTheta = Math.sin(theta);
489
- const cosTheta = Math.cos(theta);
490
- return new THREE4__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
948
+ const v = currentProjection.inverse(uvX, uvY, fovRad);
949
+ return new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
491
950
  }
492
951
  function getMouseWorldVector(pixelX, pixelY, width, height) {
493
952
  const aspect = width / height;
494
953
  const ndcX = pixelX / width * 2 - 1;
495
954
  const ndcY = -(pixelY / height) * 2 + 1;
496
- const blend = getBlendFactor(state.fov);
955
+ syncProjectionState();
497
956
  const fovRad = state.fov * Math.PI / 180;
498
- const uvX = ndcX * aspect;
499
- const uvY = ndcY;
500
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
501
- const halfHeightLinear = Math.tan(fovRad / 2);
502
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
503
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
504
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
505
- const theta = mix(theta_lin, theta_str, blend);
506
- const phi = Math.atan2(uvY, uvX);
507
- const sinTheta = Math.sin(theta);
508
- const cosTheta = Math.cos(theta);
509
- const vView = new THREE4__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
957
+ const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
958
+ const vView = new THREE5__namespace.Vector3(v.x, v.y, v.z).normalize();
510
959
  return vView.applyQuaternion(camera.quaternion);
511
960
  }
512
961
  function smartProjectJS(worldPos) {
513
962
  const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
514
963
  const dir = viewPos.clone().normalize();
515
- const zLinear = Math.max(0.01, -dir.z);
516
- const kStereo = 2 / (1 - dir.z);
517
- const kLinear = 1 / zLinear;
518
- const blend = globalUniforms.uBlend.value;
519
- const k = mix(kLinear, kStereo, blend);
520
- return { x: k * dir.x, y: k * dir.y, z: dir.z };
964
+ const result = currentProjection.forward(dir);
965
+ if (!result) return { x: 0, y: 0, z: dir.z };
966
+ return result;
521
967
  }
522
- const groundGroup = new THREE4__namespace.Group();
968
+ const groundGroup = new THREE5__namespace.Group();
523
969
  scene.add(groundGroup);
524
970
  function createGround() {
525
971
  groundGroup.clear();
526
972
  const radius = 995;
527
- const geometry = new THREE4__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2);
973
+ const geometry = new THREE5__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
528
974
  const material = createSmartMaterial({
529
- uniforms: { color: { value: new THREE4__namespace.Color(526862) } },
530
- vertexShaderBody: `varying vec3 vPos; void main() { vPos = position; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
531
- fragmentShader: `uniform vec3 color; varying vec3 vPos; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; float noise = sin(vPos.x * 0.2) * sin(vPos.z * 0.2) * 0.05; vec3 col = color + noise; vec3 n = normalize(vPos); float horizon = smoothstep(-0.02, 0.0, n.y); col += vec3(0.1, 0.15, 0.2) * horizon; gl_FragColor = vec4(col, 1.0); }`,
532
- side: THREE4__namespace.BackSide,
975
+ uniforms: {
976
+ color: { value: new THREE5__namespace.Color(65794) },
977
+ fogColor: { value: new THREE5__namespace.Color(663098) }
978
+ },
979
+ vertexShaderBody: `
980
+ varying vec3 vPos;
981
+ varying vec3 vWorldPos;
982
+ void main() {
983
+ vPos = position;
984
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
985
+ gl_Position = smartProject(mvPosition);
986
+ vScreenPos = gl_Position.xy / gl_Position.w;
987
+ vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
988
+ }
989
+ `,
990
+ fragmentShader: `
991
+ uniform vec3 color;
992
+ uniform vec3 fogColor;
993
+ varying vec3 vPos;
994
+ varying vec3 vWorldPos;
995
+
996
+ void main() {
997
+ float alphaMask = getMaskAlpha();
998
+ if (alphaMask < 0.01) discard;
999
+
1000
+ // Procedural Horizon (Mountains)
1001
+ float angle = atan(vPos.z, vPos.x);
1002
+
1003
+ // FBM-like terrain with increased amplitude
1004
+ float h = 0.0;
1005
+ h += sin(angle * 6.0) * 35.0;
1006
+ h += sin(angle * 13.0 + 1.0) * 18.0;
1007
+ h += sin(angle * 29.0 + 2.0) * 8.0;
1008
+ h += sin(angle * 63.0 + 4.0) * 3.0;
1009
+ h += sin(angle * 97.0 + 5.0) * 1.5;
1010
+
1011
+ float terrainHeight = h + 12.0;
1012
+
1013
+ if (vPos.y > terrainHeight) discard;
1014
+
1015
+ // Atmospheric rim glow just below terrain peaks
1016
+ float rimDist = terrainHeight - vPos.y;
1017
+ float rim = exp(-rimDist * 0.15) * 0.4;
1018
+ vec3 rimColor = fogColor * 1.5;
1019
+
1020
+ // Atmospheric haze \u2014 stronger near horizon
1021
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1022
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1023
+
1024
+ // Add rim glow near terrain peaks
1025
+ finalCol += rimColor * rim;
1026
+
1027
+ gl_FragColor = vec4(finalCol, 1.0);
1028
+ }
1029
+ `,
1030
+ side: THREE5__namespace.BackSide,
533
1031
  transparent: false,
534
1032
  depthWrite: true,
535
1033
  depthTest: true
536
1034
  });
537
- const ground = new THREE4__namespace.Mesh(geometry, material);
1035
+ const ground = new THREE5__namespace.Mesh(geometry, material);
538
1036
  groundGroup.add(ground);
539
- const boxGeo = new THREE4__namespace.BoxGeometry(8, 30, 8);
540
- for (let i = 0; i < 12; i++) {
541
- const angle = i / 12 * Math.PI * 2;
542
- const b = new THREE4__namespace.Mesh(boxGeo, material);
543
- const r = radius * 0.98;
544
- b.position.set(Math.cos(angle) * r, -15, Math.sin(angle) * r);
545
- b.lookAt(0, 0, 0);
546
- groundGroup.add(b);
547
- }
548
1037
  }
1038
+ let atmosphereMesh = null;
549
1039
  function createAtmosphere() {
550
- const geometry = new THREE4__namespace.SphereGeometry(990, 128, 64);
1040
+ const geometry = new THREE5__namespace.SphereGeometry(990, 64, 64);
551
1041
  const material = createSmartMaterial({
552
- uniforms: { top: { value: new THREE4__namespace.Color(0) }, bot: { value: new THREE4__namespace.Color(1712172) } },
553
1042
  vertexShaderBody: `
554
-
555
- varying vec3 vP;
556
-
557
- void main() {
558
-
559
- vP = position;
560
-
561
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
562
-
563
- gl_Position = smartProject(mv);
564
-
565
- vScreenPos = gl_Position.xy / gl_Position.w;
566
-
567
- }
568
-
569
- `,
1043
+ varying vec3 vWorldNormal;
1044
+ void main() {
1045
+ vWorldNormal = normalize(position);
1046
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
1047
+ gl_Position = smartProject(mv);
1048
+ vScreenPos = gl_Position.xy / gl_Position.w;
1049
+ }`,
570
1050
  fragmentShader: `
571
-
572
- uniform vec3 top;
573
-
574
- uniform vec3 bot;
575
-
576
- varying vec3 vP;
577
-
578
- void main() {
579
-
580
- float alphaMask = getMaskAlpha();
581
-
582
- if (alphaMask < 0.01) discard;
583
-
584
- vec3 n = normalize(vP);
585
-
586
- float h = max(0.0, n.y);
587
-
588
- gl_FragColor = vec4(mix(bot, top, pow(h, 0.6)), 1.0);
589
-
590
- }
591
-
592
- `,
593
- side: THREE4__namespace.BackSide,
1051
+ varying vec3 vWorldNormal;
1052
+
1053
+ uniform float uAtmGlow;
1054
+ uniform float uAtmDark;
1055
+ uniform vec3 uColorHorizon;
1056
+ uniform vec3 uColorZenith;
1057
+
1058
+ void main() {
1059
+ float alphaMask = getMaskAlpha();
1060
+ if (alphaMask < 0.01) discard;
1061
+
1062
+ // Altitude angle (Y is up)
1063
+ float h = normalize(vWorldNormal).y;
1064
+
1065
+ // 1. Base gradient from Horizon to Zenith (wider range)
1066
+ float t = smoothstep(-0.15, 0.7, h);
1067
+
1068
+ // Non-linear mix for realistic sky falloff
1069
+ vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
1070
+
1071
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
1072
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1073
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
1074
+
1075
+ // 3. Primary horizon glow band (wider than before)
1076
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
1077
+ skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
1078
+
1079
+ // 4. Warm secondary glow (light pollution / sodium scatter)
1080
+ float warmGlow = exp(-8.0 * abs(h));
1081
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1082
+
1083
+ gl_FragColor = vec4(skyColor, 1.0);
1084
+ }
1085
+ `,
1086
+ side: THREE5__namespace.BackSide,
594
1087
  depthWrite: false,
595
1088
  depthTest: true
596
1089
  });
597
- const atm = new THREE4__namespace.Mesh(geometry, material);
1090
+ const atm = new THREE5__namespace.Mesh(geometry, material);
1091
+ atmosphereMesh = atm;
598
1092
  groundGroup.add(atm);
599
1093
  }
600
- const backdropGroup = new THREE4__namespace.Group();
1094
+ const backdropGroup = new THREE5__namespace.Group();
601
1095
  scene.add(backdropGroup);
602
- function createBackdropStars() {
1096
+ function createBackdropStars(count = 31e3) {
603
1097
  backdropGroup.clear();
604
- const geometry = new THREE4__namespace.BufferGeometry();
1098
+ while (backdropGroup.children.length > 0) {
1099
+ const c = backdropGroup.children[0];
1100
+ backdropGroup.remove(c);
1101
+ if (c.geometry) c.geometry.dispose();
1102
+ if (c.material) c.material.dispose();
1103
+ }
1104
+ const geometry = new THREE5__namespace.BufferGeometry();
605
1105
  const positions = [];
606
1106
  const sizes = [];
607
1107
  const colors = [];
608
- const colorPalette = [
609
- new THREE4__namespace.Color(10203391),
610
- new THREE4__namespace.Color(11190271),
611
- new THREE4__namespace.Color(13293567),
612
- new THREE4__namespace.Color(16316415),
613
- new THREE4__namespace.Color(16774378),
614
- new THREE4__namespace.Color(16765601),
615
- new THREE4__namespace.Color(16764015)
616
- ];
617
1108
  const r = 2500;
618
- new THREE4__namespace.Vector3(0, 1, 0.5).normalize();
619
- for (let i = 0; i < 4e3; i++) {
620
- const isMilkyWay = Math.random() < 0.4;
621
- let x, y, z;
622
- if (isMilkyWay) {
623
- const theta = Math.random() * Math.PI * 2;
624
- const scatter = (Math.random() - 0.5) * 0.4;
625
- const v = new THREE4__namespace.Vector3(Math.cos(theta), scatter, Math.sin(theta));
626
- v.normalize();
627
- v.applyAxisAngle(new THREE4__namespace.Vector3(1, 0, 0), THREE4__namespace.MathUtils.degToRad(60));
628
- x = v.x * r;
629
- y = v.y * r;
630
- z = v.z * r;
631
- } else {
632
- const u = Math.random();
633
- const v = Math.random();
634
- const theta = 2 * Math.PI * u;
635
- const phi = Math.acos(2 * v - 1);
636
- x = r * Math.sin(phi) * Math.cos(theta);
637
- y = r * Math.sin(phi) * Math.sin(theta);
638
- z = r * Math.cos(phi);
639
- }
1109
+ for (let i = 0; i < count; i++) {
1110
+ const u = Math.random();
1111
+ const v = Math.random();
1112
+ const theta = 2 * Math.PI * u;
1113
+ const phi = Math.acos(2 * v - 1);
1114
+ const x = r * Math.sin(phi) * Math.cos(theta);
1115
+ const y = r * Math.cos(phi);
1116
+ const z = r * Math.sin(phi) * Math.sin(theta);
640
1117
  positions.push(x, y, z);
641
- const size = 0.5 + -Math.log(Math.random()) * 0.8 * 1.5;
1118
+ const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
642
1119
  sizes.push(size);
643
- const cIndex = Math.floor(Math.random() * colorPalette.length);
644
- const c = colorPalette[cIndex];
645
- colors.push(c.r, c.g, c.b);
1120
+ const temp = Math.random();
1121
+ let cr, cg, cb;
1122
+ if (temp < 0.15) {
1123
+ cr = 0.7 + temp * 2;
1124
+ cg = 0.8 + temp;
1125
+ cb = 1;
1126
+ } else if (temp < 0.6) {
1127
+ const t = (temp - 0.15) / 0.45;
1128
+ cr = 1;
1129
+ cg = 1 - t * 0.1;
1130
+ cb = 1 - t * 0.3;
1131
+ } else {
1132
+ const t = (temp - 0.6) / 0.4;
1133
+ cr = 1;
1134
+ cg = 0.85 - t * 0.35;
1135
+ cb = 0.7 - t * 0.35;
1136
+ }
1137
+ colors.push(cr, cg, cb);
646
1138
  }
647
- geometry.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(positions, 3));
648
- geometry.setAttribute("size", new THREE4__namespace.Float32BufferAttribute(sizes, 1));
649
- geometry.setAttribute("color", new THREE4__namespace.Float32BufferAttribute(colors, 3));
1139
+ geometry.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(positions, 3));
1140
+ geometry.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(sizes, 1));
1141
+ geometry.setAttribute("color", new THREE5__namespace.Float32BufferAttribute(colors, 3));
650
1142
  const material = createSmartMaterial({
651
- uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
1143
+ uniforms: {
1144
+ pixelRatio: { value: renderer.getPixelRatio() },
1145
+ uScale: globalUniforms.uScale,
1146
+ uTime: globalUniforms.uTime
1147
+ },
652
1148
  vertexShaderBody: `
653
- attribute float size;
654
- attribute vec3 color;
655
- varying vec3 vColor;
656
- uniform float pixelRatio;
657
- void main() {
658
- vColor = color;
659
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
660
- gl_Position = smartProject(mvPosition);
661
- vScreenPos = gl_Position.xy / gl_Position.w;
662
- gl_PointSize = size * pixelRatio * (600.0 / -mvPosition.z);
1149
+ attribute float size;
1150
+ attribute vec3 color;
1151
+ varying vec3 vColor;
1152
+ uniform float pixelRatio;
1153
+
1154
+ uniform float uAtmExtinction;
1155
+ uniform float uAtmTwinkle;
1156
+ uniform float uTime;
1157
+
1158
+ void main() {
1159
+ vec3 nPos = normalize(position);
1160
+ float altitude = nPos.y;
1161
+
1162
+ // Extinction & Horizon Fade
1163
+ float horizonFade = smoothstep(-0.1, 0.1, altitude);
1164
+ float airmass = 1.0 / (max(0.05, altitude + 0.05));
1165
+ float extinction = exp(-uAtmExtinction * 0.15 * airmass);
1166
+
1167
+ // Scintillation (twinkling) \u2014 stronger near horizon
1168
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1169
+ float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1170
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
1171
+
1172
+ vColor = color * 3.0 * extinction * horizonFade * scintillation;
1173
+
1174
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1175
+ gl_Position = smartProject(mvPosition);
1176
+ vScreenPos = gl_Position.xy / gl_Position.w;
1177
+
1178
+ float zoomScale = pow(uScale, 0.5);
1179
+ float perceptualSize = pow(size, 0.55);
1180
+ gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
663
1181
  }
664
1182
  `,
665
1183
  fragmentShader: `
666
- varying vec3 vColor;
667
- void main() {
668
- vec2 coord = gl_PointCoord - vec2(0.5);
669
- float dist = length(coord) * 2.0;
670
- if (dist > 1.0) discard;
671
- float alphaMask = getMaskAlpha();
672
- if (alphaMask < 0.01) discard;
673
- // Use same Gaussian glow for backdrop
674
- float alpha = exp(-3.0 * dist * dist);
675
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1184
+ varying vec3 vColor;
1185
+ void main() {
1186
+ vec2 coord = gl_PointCoord - vec2(0.5);
1187
+ float d = length(coord) * 2.0;
1188
+ if (d > 1.0) discard;
1189
+ float alphaMask = getMaskAlpha();
1190
+ if (alphaMask < 0.01) discard;
1191
+
1192
+ // Stellarium-style: sharp core + soft glow
1193
+ float core = smoothstep(0.8, 0.4, d);
1194
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1195
+ float k = core + glow;
1196
+
1197
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.5);
1198
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
676
1199
  }
677
1200
  `,
678
1201
  transparent: true,
679
1202
  depthWrite: false,
680
- depthTest: true
1203
+ depthTest: true,
1204
+ blending: THREE5__namespace.AdditiveBlending
681
1205
  });
682
- const points = new THREE4__namespace.Points(geometry, material);
1206
+ const points = new THREE5__namespace.Points(geometry, material);
683
1207
  points.frustumCulled = false;
684
1208
  backdropGroup.add(points);
685
1209
  }
686
1210
  createGround();
687
1211
  createAtmosphere();
688
1212
  createBackdropStars();
689
- const raycaster = new THREE4__namespace.Raycaster();
1213
+ const raycaster = new THREE5__namespace.Raycaster();
690
1214
  raycaster.params.Points.threshold = 5;
691
- new THREE4__namespace.Vector2();
692
- const root = new THREE4__namespace.Group();
1215
+ new THREE5__namespace.Vector2();
1216
+ const root = new THREE5__namespace.Group();
693
1217
  scene.add(root);
694
1218
  const nodeById = /* @__PURE__ */ new Map();
695
1219
  const starIndexToId = [];
@@ -697,7 +1221,7 @@ function createEngine({
697
1221
  const hoverLabelMat = createSmartMaterial({
698
1222
  uniforms: {
699
1223
  uMap: { value: null },
700
- uSize: { value: new THREE4__namespace.Vector2(1, 1) },
1224
+ uSize: { value: new THREE5__namespace.Vector2(1, 1) },
701
1225
  uAlpha: { value: 0 },
702
1226
  uAngle: { value: 0 }
703
1227
  },
@@ -735,7 +1259,7 @@ function createEngine({
735
1259
  depthTest: false
736
1260
  // Always on top of stars
737
1261
  });
738
- const hoverLabelMesh = new THREE4__namespace.Mesh(new THREE4__namespace.PlaneGeometry(1, 1), hoverLabelMat);
1262
+ const hoverLabelMesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), hoverLabelMat);
739
1263
  hoverLabelMesh.visible = false;
740
1264
  hoverLabelMesh.renderOrder = 999;
741
1265
  hoverLabelMesh.frustumCulled = false;
@@ -744,6 +1268,9 @@ function createEngine({
744
1268
  let constellationLines = null;
745
1269
  let boundaryLines = null;
746
1270
  let starPoints = null;
1271
+ const linesFader = new Fader(0.4);
1272
+ const artFader = new Fader(0.5);
1273
+ let lastTickTime = 0;
747
1274
  function clearRoot() {
748
1275
  for (const child of [...root.children]) {
749
1276
  root.remove(child);
@@ -766,19 +1293,20 @@ function createEngine({
766
1293
  const ctx = canvas.getContext("2d");
767
1294
  if (!ctx) return null;
768
1295
  const fontSize = 96;
769
- ctx.font = `bold ${fontSize}px sans-serif`;
1296
+ const font = `400 ${fontSize}px "Inter", system-ui, sans-serif`;
1297
+ ctx.font = font;
770
1298
  const metrics = ctx.measureText(text);
771
1299
  const w = Math.ceil(metrics.width);
772
1300
  const h = Math.ceil(fontSize * 1.2);
773
1301
  canvas.width = w;
774
1302
  canvas.height = h;
775
- ctx.font = `bold ${fontSize}px sans-serif`;
1303
+ ctx.font = font;
776
1304
  ctx.fillStyle = color;
777
1305
  ctx.textAlign = "center";
778
1306
  ctx.textBaseline = "middle";
779
1307
  ctx.fillText(text, w / 2, h / 2);
780
- const tex = new THREE4__namespace.CanvasTexture(canvas);
781
- tex.minFilter = THREE4__namespace.LinearFilter;
1308
+ const tex = new THREE5__namespace.CanvasTexture(canvas);
1309
+ tex.minFilter = THREE5__namespace.LinearFilter;
782
1310
  return { tex, aspect: w / h };
783
1311
  }
784
1312
  function getPosition(n) {
@@ -792,27 +1320,30 @@ function createEngine({
792
1320
  const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
793
1321
  const phi = Math.atan2(y, x);
794
1322
  const theta = r_norm * (Math.PI / 2);
795
- return new THREE4__namespace.Vector3(
1323
+ return new THREE5__namespace.Vector3(
796
1324
  Math.sin(theta) * Math.cos(phi),
797
1325
  Math.cos(theta),
798
1326
  Math.sin(theta) * Math.sin(phi)
799
1327
  ).multiplyScalar(radius);
800
1328
  }
801
- return new THREE4__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
1329
+ return new THREE5__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
802
1330
  }
803
1331
  }
804
- return new THREE4__namespace.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
1332
+ return new THREE5__namespace.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
805
1333
  }
806
1334
  function getBoundaryPoint(angle, t, radius) {
807
1335
  const y = 0.05 + t * (1 - 0.05);
808
1336
  const rY = Math.sqrt(1 - y * y);
809
1337
  const x = Math.cos(angle) * rY;
810
1338
  const z = Math.sin(angle) * rY;
811
- return new THREE4__namespace.Vector3(x, y, z).multiplyScalar(radius);
1339
+ return new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
812
1340
  }
813
1341
  function buildFromModel(model, cfg) {
814
1342
  clearRoot();
815
- scene.background = cfg.background && cfg.background !== "transparent" ? new THREE4__namespace.Color(cfg.background) : new THREE4__namespace.Color(0);
1343
+ bookIdToIndex.clear();
1344
+ testamentToIndex.clear();
1345
+ divisionToIndex.clear();
1346
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5__namespace.Color(cfg.background) : new THREE5__namespace.Color(0);
816
1347
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
817
1348
  const laidOut = computeLayoutPositions(model, layoutCfg);
818
1349
  const divisionPositions = /* @__PURE__ */ new Map();
@@ -826,7 +1357,7 @@ function createEngine({
826
1357
  }
827
1358
  }
828
1359
  for (const [divId, books] of divMap.entries()) {
829
- const centroid = new THREE4__namespace.Vector3();
1360
+ const centroid = new THREE5__namespace.Vector3();
830
1361
  let count = 0;
831
1362
  for (const b of books) {
832
1363
  const p = getPosition(b);
@@ -842,21 +1373,26 @@ function createEngine({
842
1373
  const starPositions = [];
843
1374
  const starSizes = [];
844
1375
  const starColors = [];
1376
+ const starPhases = [];
1377
+ const starBookIndices = [];
1378
+ const starChapterIndices = [];
1379
+ const starTestamentIndices = [];
1380
+ const starDivisionIndices = [];
845
1381
  const SPECTRAL_COLORS = [
846
- new THREE4__namespace.Color(10203391),
847
- // O - Blue
848
- new THREE4__namespace.Color(11190271),
849
- // B - Blue-white
850
- new THREE4__namespace.Color(13293567),
851
- // A - White-blue
852
- new THREE4__namespace.Color(16316415),
1382
+ new THREE5__namespace.Color(14544639),
1383
+ // O - Blueish White
1384
+ new THREE5__namespace.Color(15660287),
1385
+ // B - White
1386
+ new THREE5__namespace.Color(16317695),
1387
+ // A - White
1388
+ new THREE5__namespace.Color(16777208),
853
1389
  // F - White
854
- new THREE4__namespace.Color(16774378),
855
- // G - Yellow-white
856
- new THREE4__namespace.Color(16765601),
857
- // K - Yellow-orange
858
- new THREE4__namespace.Color(16764015)
859
- // M - Orange-red
1390
+ new THREE5__namespace.Color(16775406),
1391
+ // G - Yellowish White
1392
+ new THREE5__namespace.Color(16773085),
1393
+ // K - Pale Orange
1394
+ new THREE5__namespace.Color(16771788)
1395
+ // M - Light Orange
860
1396
  ];
861
1397
  let minWeight = Infinity;
862
1398
  let maxWeight = -Infinity;
@@ -881,21 +1417,61 @@ function createEngine({
881
1417
  let baseSize = 3.5;
882
1418
  if (typeof n.weight === "number") {
883
1419
  const t = (n.weight - minWeight) / (maxWeight - minWeight);
884
- baseSize = 3 + t * 4;
1420
+ baseSize = 0.1 + Math.pow(t, 0.5) * 11.9;
885
1421
  }
886
1422
  starSizes.push(baseSize);
887
1423
  const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
888
1424
  const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
889
1425
  starColors.push(c.r, c.g, c.b);
1426
+ starPhases.push(Math.random() * Math.PI * 2);
1427
+ let bIdx = -1;
1428
+ if (n.parent) {
1429
+ if (!bookIdToIndex.has(n.parent)) {
1430
+ bookIdToIndex.set(n.parent, bookIdToIndex.size + 1);
1431
+ }
1432
+ bIdx = bookIdToIndex.get(n.parent);
1433
+ }
1434
+ starBookIndices.push(bIdx);
1435
+ let cIdx = 0;
1436
+ if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1437
+ starChapterIndices.push(cIdx);
1438
+ let tIdx = -1;
1439
+ if (n.meta?.testament) {
1440
+ const tName = n.meta.testament;
1441
+ if (!testamentToIndex.has(tName)) {
1442
+ testamentToIndex.set(tName, testamentToIndex.size + 1);
1443
+ }
1444
+ tIdx = testamentToIndex.get(tName);
1445
+ }
1446
+ starTestamentIndices.push(tIdx);
1447
+ let dIdx = -1;
1448
+ if (n.meta?.division) {
1449
+ const dName = n.meta.division;
1450
+ if (!divisionToIndex.has(dName)) {
1451
+ divisionToIndex.set(dName, divisionToIndex.size + 1);
1452
+ }
1453
+ dIdx = divisionToIndex.get(dName);
1454
+ }
1455
+ starDivisionIndices.push(dIdx);
890
1456
  }
891
1457
  if (n.level === 1 || n.level === 2 || n.level === 3) {
892
- const color = n.level === 1 ? "#38bdf8" : "#ffffff";
893
- const texRes = createTextTexture(n.label, color);
1458
+ let color = "#ffffff";
1459
+ if (n.level === 1) color = "#38bdf8";
1460
+ else if (n.level === 2) {
1461
+ const bookKey = n.meta?.bookKey;
1462
+ color = bookKey && cfg.labelColors?.[bookKey] || "#cbd5e1";
1463
+ } else if (n.level === 3) color = "#94a3b8";
1464
+ let labelText = n.label;
1465
+ if (n.level === 3 && n.meta?.chapter) {
1466
+ labelText = String(n.meta.chapter);
1467
+ }
1468
+ const texRes = createTextTexture(labelText, color);
894
1469
  if (texRes) {
895
1470
  let baseScale = 0.05;
896
1471
  if (n.level === 1) baseScale = 0.08;
897
- else if (n.level === 3) baseScale = 0.04;
898
- const size = new THREE4__namespace.Vector2(baseScale * texRes.aspect, baseScale);
1472
+ else if (n.level === 2) baseScale = 0.04;
1473
+ else if (n.level === 3) baseScale = 0.03;
1474
+ const size = new THREE5__namespace.Vector2(baseScale * texRes.aspect, baseScale);
899
1475
  const mat = createSmartMaterial({
900
1476
  uniforms: {
901
1477
  uMap: { value: texRes.tex },
@@ -936,7 +1512,7 @@ function createEngine({
936
1512
  depthWrite: false,
937
1513
  depthTest: true
938
1514
  });
939
- const mesh = new THREE4__namespace.Mesh(new THREE4__namespace.PlaneGeometry(1, 1), mat);
1515
+ const mesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), mat);
940
1516
  let p = getPosition(n);
941
1517
  if (n.level === 1) {
942
1518
  if (divisionPositions.has(n.id)) {
@@ -946,7 +1522,8 @@ function createEngine({
946
1522
  const angle = Math.atan2(p.z, p.x);
947
1523
  p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
948
1524
  } else if (n.level === 3) {
949
- p.multiplyScalar(1.002);
1525
+ p.y += 30;
1526
+ p.multiplyScalar(1.001);
950
1527
  }
951
1528
  mesh.position.set(p.x, p.y, p.z);
952
1529
  mesh.scale.set(size.x, size.y, 1);
@@ -957,47 +1534,147 @@ function createEngine({
957
1534
  }
958
1535
  }
959
1536
  }
960
- const starGeo = new THREE4__namespace.BufferGeometry();
961
- starGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(starPositions, 3));
962
- starGeo.setAttribute("size", new THREE4__namespace.Float32BufferAttribute(starSizes, 1));
963
- starGeo.setAttribute("color", new THREE4__namespace.Float32BufferAttribute(starColors, 3));
1537
+ const starGeo = new THREE5__namespace.BufferGeometry();
1538
+ starGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(starPositions, 3));
1539
+ starGeo.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(starSizes, 1));
1540
+ starGeo.setAttribute("color", new THREE5__namespace.Float32BufferAttribute(starColors, 3));
1541
+ starGeo.setAttribute("phase", new THREE5__namespace.Float32BufferAttribute(starPhases, 1));
1542
+ starGeo.setAttribute("bookIndex", new THREE5__namespace.Float32BufferAttribute(starBookIndices, 1));
1543
+ starGeo.setAttribute("chapterIndex", new THREE5__namespace.Float32BufferAttribute(starChapterIndices, 1));
1544
+ starGeo.setAttribute("testamentIndex", new THREE5__namespace.Float32BufferAttribute(starTestamentIndices, 1));
1545
+ starGeo.setAttribute("divisionIndex", new THREE5__namespace.Float32BufferAttribute(starDivisionIndices, 1));
964
1546
  const starMat = createSmartMaterial({
965
- uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
1547
+ uniforms: {
1548
+ pixelRatio: { value: renderer.getPixelRatio() },
1549
+ uScale: globalUniforms.uScale,
1550
+ uTime: globalUniforms.uTime,
1551
+ uActiveBookIndex: { value: -1 },
1552
+ uOrderRevealStrength: { value: 0 },
1553
+ uGlobalDimFactor: { value: ORDER_REVEAL_CONFIG.globalDim },
1554
+ uPulseParams: { value: new THREE5__namespace.Vector3(
1555
+ ORDER_REVEAL_CONFIG.pulseDuration,
1556
+ ORDER_REVEAL_CONFIG.delayPerChapter,
1557
+ ORDER_REVEAL_CONFIG.pulseAmplitude
1558
+ ) },
1559
+ uFilterTestamentIndex: { value: -1 },
1560
+ uFilterDivisionIndex: { value: -1 },
1561
+ uFilterBookIndex: { value: -1 },
1562
+ uFilterStrength: { value: 0 },
1563
+ uFilterDimFactor: { value: 0.08 }
1564
+ },
966
1565
  vertexShaderBody: `
967
1566
  attribute float size;
968
1567
  attribute vec3 color;
969
- varying vec3 vColor;
970
- uniform float pixelRatio;
1568
+ attribute float phase;
1569
+ attribute float bookIndex;
1570
+ attribute float chapterIndex;
1571
+ attribute float testamentIndex;
1572
+ attribute float divisionIndex;
1573
+
1574
+ varying vec3 vColor;
1575
+ uniform float pixelRatio;
1576
+
1577
+ uniform float uTime;
1578
+ uniform float uAtmExtinction;
1579
+ uniform float uAtmTwinkle;
1580
+
1581
+ uniform float uActiveBookIndex;
1582
+ uniform float uOrderRevealStrength;
1583
+ uniform float uGlobalDimFactor;
1584
+ uniform vec3 uPulseParams;
1585
+
1586
+ uniform float uFilterTestamentIndex;
1587
+ uniform float uFilterDivisionIndex;
1588
+ uniform float uFilterBookIndex;
1589
+ uniform float uFilterStrength;
1590
+ uniform float uFilterDimFactor;
1591
+
971
1592
  void main() {
972
- vColor = color;
1593
+ vec3 nPos = normalize(position);
1594
+
1595
+ // 1. Altitude (Y is UP)
1596
+ float altitude = nPos.y;
1597
+
1598
+ // 2. Atmospheric Extinction (Airmass approximation)
1599
+ float airmass = 1.0 / (max(0.02, altitude + 0.05));
1600
+ float extinction = exp(-uAtmExtinction * 0.1 * airmass);
1601
+
1602
+ // Fade out stars below horizon
1603
+ float horizonFade = smoothstep(-0.1, 0.05, altitude);
1604
+
1605
+ // 3. Scintillation
1606
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1607
+ float twinkle = sin(uTime * 3.0 + phase + position.x * 0.01) * 0.5 + 0.5;
1608
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.5 * turbulence);
1609
+
1610
+ // --- Order Reveal Logic ---
1611
+ float isTarget = 1.0 - min(1.0, abs(bookIndex - uActiveBookIndex));
1612
+
1613
+ // Dimming
1614
+ float dimFactor = mix(1.0, uGlobalDimFactor, uOrderRevealStrength * (1.0 - isTarget));
1615
+
1616
+ // Pulse
1617
+ float delay = chapterIndex * uPulseParams.y;
1618
+ float cycleDuration = uPulseParams.x * 2.5;
1619
+ float t = mod(uTime - delay, cycleDuration);
1620
+
1621
+ float pulse = smoothstep(0.0, 0.2, t) * (1.0 - smoothstep(0.4, uPulseParams.x, t));
1622
+ pulse = max(0.0, pulse);
1623
+
1624
+ float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1625
+
1626
+ // --- Hierarchy Filter ---
1627
+ float filtered = 0.0;
1628
+ if (uFilterTestamentIndex >= 0.0) {
1629
+ filtered = 1.0 - step(0.5, 1.0 - abs(testamentIndex - uFilterTestamentIndex));
1630
+ }
1631
+ if (uFilterDivisionIndex >= 0.0 && filtered < 0.5) {
1632
+ filtered = 1.0 - step(0.5, 1.0 - abs(divisionIndex - uFilterDivisionIndex));
1633
+ }
1634
+ if (uFilterBookIndex >= 0.0 && filtered < 0.5) {
1635
+ filtered = 1.0 - step(0.5, 1.0 - abs(bookIndex - uFilterBookIndex));
1636
+ }
1637
+ float filterDim = mix(1.0, uFilterDimFactor, uFilterStrength * filtered);
1638
+
1639
+ vec3 baseColor = color * extinction * horizonFade * scintillation;
1640
+ vColor = baseColor * dimFactor * filterDim;
1641
+ vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1642
+
973
1643
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
974
1644
  gl_Position = smartProject(mvPosition);
975
1645
  vScreenPos = gl_Position.xy / gl_Position.w;
976
- gl_PointSize = size * pixelRatio * (2000.0 / -mvPosition.z);
1646
+
1647
+ float sizeBoost = 1.0 + activePulse * 0.8;
1648
+ float perceptualSize = pow(size, 0.55);
1649
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
977
1650
  }
978
1651
  `,
979
1652
  fragmentShader: `
980
1653
  varying vec3 vColor;
981
1654
  void main() {
982
1655
  vec2 coord = gl_PointCoord - vec2(0.5);
983
- // Use larger drawing area for glow
984
- float dist = length(coord) * 2.0;
985
- if (dist > 1.0) discard;
1656
+ float d = length(coord) * 2.0;
1657
+ if (d > 1.0) discard;
986
1658
 
987
1659
  float alphaMask = getMaskAlpha();
988
1660
  if (alphaMask < 0.01) discard;
989
1661
 
990
- // Gaussian Glow: Sharp core, soft halo
991
- float alpha = exp(-3.0 * dist * dist);
992
-
993
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1662
+ // Stellarium-style dual-layer: sharp core + soft glow
1663
+ float core = smoothstep(0.8, 0.4, d);
1664
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1665
+ float k = core + glow;
1666
+
1667
+ // White-hot core blending into coloured halo
1668
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1669
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
994
1670
  }
995
1671
  `,
996
1672
  transparent: true,
997
1673
  depthWrite: false,
998
- depthTest: true
1674
+ depthTest: true,
1675
+ blending: THREE5__namespace.AdditiveBlending
999
1676
  });
1000
- starPoints = new THREE4__namespace.Points(starGeo, starMat);
1677
+ starPoints = new THREE5__namespace.Points(starGeo, starMat);
1001
1678
  starPoints.frustumCulled = false;
1002
1679
  root.add(starPoints);
1003
1680
  const linePoints = [];
@@ -1023,31 +1700,191 @@ function createEngine({
1023
1700
  }
1024
1701
  }
1025
1702
  if (linePoints.length > 0) {
1026
- const lineGeo = new THREE4__namespace.BufferGeometry();
1027
- lineGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(linePoints, 3));
1703
+ const quadPositions = [];
1704
+ const quadUvs = [];
1705
+ const quadIndices = [];
1706
+ const lineWidth = 8;
1707
+ for (let i = 0; i < linePoints.length; i += 6) {
1708
+ const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1709
+ const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
1710
+ const dx = bx - ax, dy = by - ay, dz = bz - az;
1711
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1712
+ if (len < 1e-3) continue;
1713
+ let px = dy * 0 - dz * 1, py = dz * 0 - dx * 0, pz = dx * 1 - dy * 0;
1714
+ const pLen = Math.sqrt(px * px + py * py + pz * pz);
1715
+ if (pLen < 1e-3) {
1716
+ px = 1;
1717
+ py = 0;
1718
+ pz = 0;
1719
+ } else {
1720
+ px /= pLen;
1721
+ py /= pLen;
1722
+ pz /= pLen;
1723
+ }
1724
+ const hw = lineWidth;
1725
+ const baseIdx = quadPositions.length / 3;
1726
+ quadPositions.push(ax - px * hw, ay - py * hw, az - pz * hw);
1727
+ quadUvs.push(0, -1);
1728
+ quadPositions.push(ax + px * hw, ay + py * hw, az + pz * hw);
1729
+ quadUvs.push(0, 1);
1730
+ quadPositions.push(bx - px * hw, by - py * hw, bz - pz * hw);
1731
+ quadUvs.push(1, -1);
1732
+ quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1733
+ quadUvs.push(1, 1);
1734
+ quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1735
+ }
1736
+ const lineGeo = new THREE5__namespace.BufferGeometry();
1737
+ lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(quadPositions, 3));
1738
+ lineGeo.setAttribute("lineUv", new THREE5__namespace.Float32BufferAttribute(quadUvs, 2));
1739
+ lineGeo.setIndex(quadIndices);
1028
1740
  const lineMat = createSmartMaterial({
1029
- uniforms: { color: { value: new THREE4__namespace.Color(11193599) } },
1030
- 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; }`,
1031
- fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.4 * alphaMask); }`,
1741
+ uniforms: {
1742
+ color: { value: new THREE5__namespace.Color(11193599) },
1743
+ uLineWidth: { value: 1.5 },
1744
+ uGlowIntensity: { value: 0.3 }
1745
+ },
1746
+ vertexShaderBody: `
1747
+ attribute vec2 lineUv;
1748
+ varying vec2 vLineUv;
1749
+ void main() {
1750
+ vLineUv = lineUv;
1751
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1752
+ gl_Position = smartProject(mvPosition);
1753
+ vScreenPos = gl_Position.xy / gl_Position.w;
1754
+ }
1755
+ `,
1756
+ fragmentShader: `
1757
+ uniform vec3 color;
1758
+ uniform float uLineWidth;
1759
+ uniform float uGlowIntensity;
1760
+ varying vec2 vLineUv;
1761
+ void main() {
1762
+ float alphaMask = getMaskAlpha();
1763
+ if (alphaMask < 0.01) discard;
1764
+
1765
+ float dist = abs(vLineUv.y);
1766
+
1767
+ // Anti-aliased core line
1768
+ float hw = uLineWidth * 0.05;
1769
+ float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1770
+
1771
+ // Soft glow extending outward
1772
+ float glow = (1.0 - dist) * uGlowIntensity;
1773
+
1774
+ float alpha = max(glow, base);
1775
+ if (alpha < 0.005) discard;
1776
+
1777
+ gl_FragColor = vec4(color, alpha * alphaMask);
1778
+ }
1779
+ `,
1032
1780
  transparent: true,
1033
1781
  depthWrite: false,
1034
- blending: THREE4__namespace.AdditiveBlending
1782
+ blending: THREE5__namespace.AdditiveBlending,
1783
+ side: THREE5__namespace.DoubleSide
1035
1784
  });
1036
- constellationLines = new THREE4__namespace.LineSegments(lineGeo, lineMat);
1785
+ constellationLines = new THREE5__namespace.Mesh(lineGeo, lineMat);
1037
1786
  constellationLines.frustumCulled = false;
1038
1787
  root.add(constellationLines);
1039
1788
  }
1789
+ if (cfg.groups) {
1790
+ for (const [bookId, chapters] of bookMap.entries()) {
1791
+ const bookNode = nodeById.get(bookId);
1792
+ if (!bookNode) continue;
1793
+ const bookName = bookNode.meta?.book || bookNode.label;
1794
+ const groupList = cfg.groups[bookName.toLowerCase()];
1795
+ if (groupList) {
1796
+ groupList.forEach((g, idx) => {
1797
+ const groupId = `G:${bookId}:${idx}`;
1798
+ let p = new THREE5__namespace.Vector3();
1799
+ if (cfg.arrangement && cfg.arrangement[groupId]) {
1800
+ const arr = cfg.arrangement[groupId];
1801
+ p.set(arr.position[0], arr.position[1], arr.position[2]);
1802
+ } else {
1803
+ const relevantChapters = chapters.filter((c) => {
1804
+ const ch = c.meta?.chapter;
1805
+ return ch >= g.start && ch <= g.end;
1806
+ });
1807
+ if (relevantChapters.length === 0) return;
1808
+ for (const c of relevantChapters) {
1809
+ p.add(getPosition(c));
1810
+ }
1811
+ p.divideScalar(relevantChapters.length);
1812
+ }
1813
+ const labelText = `${g.name} (${g.start}-${g.end})`;
1814
+ const texRes = createTextTexture(labelText, "#4fa4fa80");
1815
+ if (texRes) {
1816
+ const baseScale = 0.036;
1817
+ const size = new THREE5__namespace.Vector2(baseScale * texRes.aspect, baseScale);
1818
+ const mat = createSmartMaterial({
1819
+ uniforms: {
1820
+ uMap: { value: texRes.tex },
1821
+ uSize: { value: size },
1822
+ uAlpha: { value: 0 },
1823
+ uAngle: { value: 0 }
1824
+ },
1825
+ vertexShaderBody: `
1826
+ uniform vec2 uSize;
1827
+ uniform float uAngle;
1828
+ varying vec2 vUv;
1829
+ void main() {
1830
+ vUv = uv;
1831
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1832
+ vec4 projected = smartProject(mvPos);
1833
+
1834
+ float c = cos(uAngle);
1835
+ float s = sin(uAngle);
1836
+ mat2 rot = mat2(c, -s, s, c);
1837
+ vec2 offset = rot * (position.xy * uSize);
1838
+
1839
+ projected.xy += offset / vec2(uAspect, 1.0);
1840
+ gl_Position = projected;
1841
+ }
1842
+ `,
1843
+ fragmentShader: `
1844
+ uniform sampler2D uMap;
1845
+ uniform float uAlpha;
1846
+ varying vec2 vUv;
1847
+ void main() {
1848
+ float mask = getMaskAlpha();
1849
+ if (mask < 0.01) discard;
1850
+ vec4 tex = texture2D(uMap, vUv);
1851
+ gl_FragColor = vec4(tex.rgb, tex.a * uAlpha * mask);
1852
+ }
1853
+ `,
1854
+ transparent: true,
1855
+ depthWrite: false,
1856
+ depthTest: true
1857
+ });
1858
+ const mesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), mat);
1859
+ mesh.position.copy(p);
1860
+ mesh.scale.set(size.x, size.y, 1);
1861
+ mesh.frustumCulled = false;
1862
+ mesh.userData = { id: groupId };
1863
+ root.add(mesh);
1864
+ const node = {
1865
+ id: groupId,
1866
+ label: labelText,
1867
+ level: 2.5,
1868
+ // Special Level
1869
+ parent: bookId
1870
+ };
1871
+ dynamicLabels.push({ obj: mesh, node, initialScale: size.clone() });
1872
+ }
1873
+ });
1874
+ }
1875
+ }
1876
+ }
1040
1877
  const boundaries = laidOut.meta?.divisionBoundaries ?? [];
1041
1878
  if (boundaries.length > 0) {
1042
1879
  const boundaryMat = createSmartMaterial({
1043
- uniforms: { color: { value: new THREE4__namespace.Color(5601177) } },
1880
+ uniforms: { color: { value: new THREE5__namespace.Color(5601177) } },
1044
1881
  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; }`,
1045
1882
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
1046
1883
  transparent: true,
1047
1884
  depthWrite: false,
1048
- blending: THREE4__namespace.AdditiveBlending
1885
+ blending: THREE5__namespace.AdditiveBlending
1049
1886
  });
1050
- const boundaryGeo = new THREE4__namespace.BufferGeometry();
1887
+ const boundaryGeo = new THREE5__namespace.BufferGeometry();
1051
1888
  const bPoints = [];
1052
1889
  boundaries.forEach((angle) => {
1053
1890
  const steps = 32;
@@ -1060,8 +1897,8 @@ function createEngine({
1060
1897
  bPoints.push(p2.x, p2.y, p2.z);
1061
1898
  }
1062
1899
  });
1063
- boundaryGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(bPoints, 3));
1064
- boundaryLines = new THREE4__namespace.LineSegments(boundaryGeo, boundaryMat);
1900
+ boundaryGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(bPoints, 3));
1901
+ boundaryLines = new THREE5__namespace.LineSegments(boundaryGeo, boundaryMat);
1065
1902
  boundaryLines.frustumCulled = false;
1066
1903
  root.add(boundaryLines);
1067
1904
  }
@@ -1080,7 +1917,7 @@ function createEngine({
1080
1917
  const r_norm = Math.sqrt(x * x + y * y);
1081
1918
  const phi = Math.atan2(y, x);
1082
1919
  const theta = r_norm * (Math.PI / 2);
1083
- return new THREE4__namespace.Vector3(
1920
+ return new THREE5__namespace.Vector3(
1084
1921
  Math.sin(theta) * Math.cos(phi),
1085
1922
  Math.cos(theta),
1086
1923
  Math.sin(theta) * Math.sin(phi)
@@ -1093,18 +1930,18 @@ function createEngine({
1093
1930
  }
1094
1931
  }
1095
1932
  if (polyPoints.length > 0) {
1096
- const polyGeo = new THREE4__namespace.BufferGeometry();
1097
- polyGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(polyPoints, 3));
1933
+ const polyGeo = new THREE5__namespace.BufferGeometry();
1934
+ polyGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(polyPoints, 3));
1098
1935
  const polyMat = createSmartMaterial({
1099
- uniforms: { color: { value: new THREE4__namespace.Color(3718648) } },
1936
+ uniforms: { color: { value: new THREE5__namespace.Color(3718648) } },
1100
1937
  // Cyan-ish
1101
1938
  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; }`,
1102
1939
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
1103
1940
  transparent: true,
1104
1941
  depthWrite: false,
1105
- blending: THREE4__namespace.AdditiveBlending
1942
+ blending: THREE5__namespace.AdditiveBlending
1106
1943
  });
1107
- const polyLines = new THREE4__namespace.LineSegments(polyGeo, polyMat);
1944
+ const polyLines = new THREE5__namespace.LineSegments(polyGeo, polyMat);
1108
1945
  polyLines.frustumCulled = false;
1109
1946
  root.add(polyLines);
1110
1947
  }
@@ -1116,8 +1953,16 @@ function createEngine({
1116
1953
  let lastModel = void 0;
1117
1954
  let lastAppliedLon = void 0;
1118
1955
  let lastAppliedLat = void 0;
1956
+ let lastBackdropCount = void 0;
1957
+ function setProjection(id) {
1958
+ const factory = exports.PROJECTIONS[id];
1959
+ if (!factory) return;
1960
+ currentProjection = factory();
1961
+ updateUniforms();
1962
+ }
1119
1963
  function setConfig(cfg) {
1120
1964
  currentConfig = cfg;
1965
+ if (cfg.projection) setProjection(cfg.projection);
1121
1966
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1122
1967
  state.lon = cfg.camera.lon;
1123
1968
  state.targetLon = cfg.camera.lon;
@@ -1128,6 +1973,11 @@ function createEngine({
1128
1973
  state.targetLat = cfg.camera.lat;
1129
1974
  lastAppliedLat = cfg.camera.lat;
1130
1975
  }
1976
+ const desiredBackdropCount = typeof cfg.backdropStarsCount === "number" ? cfg.backdropStarsCount : 4e3;
1977
+ if (lastBackdropCount !== desiredBackdropCount) {
1978
+ createBackdropStars(desiredBackdropCount);
1979
+ lastBackdropCount = desiredBackdropCount;
1980
+ }
1131
1981
  let shouldRebuild = false;
1132
1982
  let model = cfg.model;
1133
1983
  if (!model && cfg.data && cfg.adapter) {
@@ -1151,6 +2001,29 @@ function createEngine({
1151
2001
  } else if (cfg.arrangement && starPoints) {
1152
2002
  if (lastModel) buildFromModel(lastModel, cfg);
1153
2003
  }
2004
+ if (cfg.constellations) {
2005
+ constellationLayer.load(cfg.constellations, (id) => {
2006
+ if (cfg.arrangement && cfg.arrangement[id]) {
2007
+ const arr = cfg.arrangement[id];
2008
+ if (arr.position[2] === 0) {
2009
+ const x = arr.position[0];
2010
+ const y = arr.position[1];
2011
+ const radius = cfg.layout?.radius ?? 2e3;
2012
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
2013
+ const phi = Math.atan2(y, x);
2014
+ const theta = r_norm * (Math.PI / 2);
2015
+ return new THREE5__namespace.Vector3(
2016
+ Math.sin(theta) * Math.cos(phi),
2017
+ Math.cos(theta),
2018
+ Math.sin(theta) * Math.sin(phi)
2019
+ ).multiplyScalar(radius);
2020
+ }
2021
+ return new THREE5__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2022
+ }
2023
+ const n = nodeById.get(id);
2024
+ return n ? getPosition(n) : null;
2025
+ });
2026
+ }
1154
2027
  }
1155
2028
  function setHandlers(next) {
1156
2029
  handlers = next;
@@ -1170,27 +2043,42 @@ function createEngine({
1170
2043
  }
1171
2044
  }
1172
2045
  for (const item of dynamicLabels) {
2046
+ if (item.node.level === 3) continue;
1173
2047
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
1174
2048
  }
2049
+ for (const item of constellationLayer.getItems()) {
2050
+ arr[item.config.id] = { position: [item.mesh.position.x, item.mesh.position.y, item.mesh.position.z] };
2051
+ }
1175
2052
  Object.assign(arr, state.tempArrangement);
1176
2053
  return arr;
1177
2054
  }
2055
+ function isNodeFiltered(node) {
2056
+ if (!currentFilter) return false;
2057
+ const meta = node.meta;
2058
+ if (!meta) return false;
2059
+ if (currentFilter.testament && meta.testament !== currentFilter.testament) return true;
2060
+ if (currentFilter.division && meta.division !== currentFilter.division) return true;
2061
+ if (currentFilter.bookKey && meta.bookKey !== currentFilter.bookKey) return true;
2062
+ return false;
2063
+ }
1178
2064
  function pick(ev) {
1179
2065
  const rect = renderer.domElement.getBoundingClientRect();
1180
2066
  const mX = ev.clientX - rect.left;
1181
2067
  const mY = ev.clientY - rect.top;
1182
2068
  mouseNDC.x = mX / rect.width * 2 - 1;
1183
2069
  mouseNDC.y = -(mY / rect.height) * 2 + 1;
1184
- let closestLabel = null;
1185
- let minLabelDist = 40;
1186
2070
  const uScale = globalUniforms.uScale.value;
1187
2071
  const uAspect = camera.aspect;
1188
2072
  const w = rect.width;
1189
2073
  const h = rect.height;
2074
+ let closestLabel = null;
2075
+ let minLabelDist = 40;
1190
2076
  for (const item of dynamicLabels) {
1191
2077
  if (!item.obj.visible) continue;
2078
+ if (isNodeFiltered(item.node)) continue;
1192
2079
  const pWorld = item.obj.position;
1193
2080
  const pProj = smartProjectJS(pWorld);
2081
+ if (currentProjection.isClipped(pProj.z)) continue;
1194
2082
  const xNDC = pProj.x * uScale / uAspect;
1195
2083
  const yNDC = pProj.y * uScale;
1196
2084
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1198,28 +2086,79 @@ function createEngine({
1198
2086
  const dx = mX - sX;
1199
2087
  const dy = mY - sY;
1200
2088
  const d = Math.sqrt(dx * dx + dy * dy);
1201
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1202
- if (!isBehind && d < minLabelDist) {
2089
+ if (d < minLabelDist) {
1203
2090
  minLabelDist = d;
1204
2091
  closestLabel = item;
1205
2092
  }
1206
2093
  }
1207
- if (closestLabel) return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
1208
- const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
1209
- raycaster.ray.origin.set(0, 0, 0);
1210
- raycaster.ray.direction.copy(worldDir);
1211
- raycaster.params.Points.threshold = 5 * (state.fov / 60);
1212
- const hits = raycaster.intersectObject(starPoints, false);
1213
- const pointHit = hits[0];
1214
- if (pointHit && pointHit.index !== void 0) {
1215
- const id = starIndexToId[pointHit.index];
1216
- if (id) {
1217
- const node = nodeById.get(id);
1218
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2094
+ if (closestLabel) {
2095
+ return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
2096
+ }
2097
+ let closestConst = null;
2098
+ let minConstDist = Infinity;
2099
+ for (const item of constellationLayer.getItems()) {
2100
+ if (!item.mesh.visible) continue;
2101
+ const pWorld = item.mesh.position;
2102
+ const pProj = smartProjectJS(pWorld);
2103
+ if (currentProjection.isClipped(pProj.z)) continue;
2104
+ const uniforms = item.material.uniforms;
2105
+ if (!uniforms || !uniforms.uSize) continue;
2106
+ const uSize = uniforms.uSize.value;
2107
+ const uImgAspect = uniforms.uImgAspect.value;
2108
+ const uImgRotation = uniforms.uImgRotation.value;
2109
+ const dist = pWorld.length();
2110
+ if (dist < 1e-3) continue;
2111
+ const scale = uSize / dist * uScale;
2112
+ const halfH_px = scale / 2 * (h / 2);
2113
+ const halfW_px = halfH_px * uImgAspect;
2114
+ const xNDC = pProj.x * uScale / uAspect;
2115
+ const yNDC = pProj.y * uScale;
2116
+ const sX = (xNDC * 0.5 + 0.5) * w;
2117
+ const sY = (-yNDC * 0.5 + 0.5) * h;
2118
+ const dx = mX - sX;
2119
+ const dy = mY - sY;
2120
+ const dy_cart = -dy;
2121
+ const cr = Math.cos(-uImgRotation);
2122
+ const sr = Math.sin(-uImgRotation);
2123
+ const localX = dx * cr - dy_cart * sr;
2124
+ const localY = dx * sr + dy_cart * cr;
2125
+ if (Math.abs(localX) < halfW_px * 1.2 && Math.abs(localY) < halfH_px * 1.2) {
2126
+ const d = Math.sqrt(dx * dx + dy * dy);
2127
+ if (!closestConst || d < minConstDist) {
2128
+ minConstDist = d;
2129
+ closestConst = item;
2130
+ }
2131
+ }
2132
+ }
2133
+ if (closestConst) {
2134
+ const fakeNode = {
2135
+ id: closestConst.config.id,
2136
+ label: closestConst.config.title,
2137
+ level: -1
2138
+ };
2139
+ return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.mesh.position.clone(), index: void 0 };
2140
+ }
2141
+ if (starPoints) {
2142
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
2143
+ raycaster.ray.origin.set(0, 0, 0);
2144
+ raycaster.ray.direction.copy(worldDir);
2145
+ raycaster.params.Points.threshold = 5 * (state.fov / 60);
2146
+ const hits = raycaster.intersectObject(starPoints, false);
2147
+ const pointHit = hits[0];
2148
+ if (pointHit && pointHit.index !== void 0) {
2149
+ const id = starIndexToId[pointHit.index];
2150
+ if (id) {
2151
+ const node = nodeById.get(id);
2152
+ if (node && !isNodeFiltered(node)) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2153
+ }
1219
2154
  }
1220
2155
  }
1221
2156
  return void 0;
1222
2157
  }
2158
+ function onWindowBlur() {
2159
+ isMouseInWindow = false;
2160
+ edgeHoverStart = 0;
2161
+ }
1223
2162
  function onMouseDown(e) {
1224
2163
  state.lastMouseX = e.clientX;
1225
2164
  state.lastMouseY = e.clientY;
@@ -1243,17 +2182,21 @@ function createEngine({
1243
2182
  if (starId) {
1244
2183
  const starNode = nodeById.get(starId);
1245
2184
  if (starNode && starNode.parent === bookId) {
1246
- children.push({ index: i, initialPos: new THREE4__namespace.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
2185
+ children.push({ index: i, initialPos: new THREE5__namespace.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
1247
2186
  }
1248
2187
  }
1249
2188
  }
1250
2189
  }
1251
2190
  state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
1252
2191
  state.draggedStarIndex = -1;
2192
+ } else if (hit.type === "constellation") {
2193
+ state.draggedGroup = null;
2194
+ state.draggedStarIndex = -1;
1253
2195
  }
1254
- return;
1255
2196
  }
2197
+ return;
1256
2198
  }
2199
+ flyToActive = false;
1257
2200
  state.dragMode = "camera";
1258
2201
  state.isDragging = true;
1259
2202
  state.velocityX = 0;
@@ -1282,13 +2225,19 @@ function createEngine({
1282
2225
  if (item) {
1283
2226
  item.obj.position.copy(newPos);
1284
2227
  state.tempArrangement[item.node.id] = { position: [newPos.x, newPos.y, newPos.z] };
2228
+ } else if (state.draggedNodeId) {
2229
+ const cItem = constellationLayer.getItems().find((c) => c.config.id === state.draggedNodeId);
2230
+ if (cItem) {
2231
+ cItem.mesh.position.copy(newPos);
2232
+ state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
2233
+ }
1285
2234
  }
1286
2235
  const vStart = group.labelInitialPos.clone().normalize();
1287
2236
  const vEnd = newPos.clone().normalize();
1288
- const q = new THREE4__namespace.Quaternion().setFromUnitVectors(vStart, vEnd);
2237
+ const q = new THREE5__namespace.Quaternion().setFromUnitVectors(vStart, vEnd);
1289
2238
  if (starPoints && group.children.length > 0) {
1290
2239
  const attr = starPoints.geometry.attributes.position;
1291
- const tempVec = new THREE4__namespace.Vector3();
2240
+ const tempVec = new THREE5__namespace.Vector3();
1292
2241
  for (const child of group.children) {
1293
2242
  tempVec.copy(child.initialPos).applyQuaternion(q);
1294
2243
  attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
@@ -1306,11 +2255,13 @@ function createEngine({
1306
2255
  state.lastMouseX = e.clientX;
1307
2256
  state.lastMouseY = e.clientY;
1308
2257
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2258
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2259
+ const latFactor = 1 - rotLock * rotLock;
1309
2260
  state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1310
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2261
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1311
2262
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1312
2263
  state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1313
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2264
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1314
2265
  state.lon = state.targetLon;
1315
2266
  state.lat = state.targetLat;
1316
2267
  } else {
@@ -1322,7 +2273,7 @@ function createEngine({
1322
2273
  if (res) {
1323
2274
  hoverLabelMat.uniforms.uMap.value = res.tex;
1324
2275
  const baseScale = 0.03;
1325
- const size = new THREE4__namespace.Vector2(baseScale * res.aspect, baseScale);
2276
+ const size = new THREE5__namespace.Vector2(baseScale * res.aspect, baseScale);
1326
2277
  hoverLabelMat.uniforms.uSize.value = size;
1327
2278
  hoverLabelMesh.scale.set(size.x, size.y, 1);
1328
2279
  }
@@ -1338,11 +2289,15 @@ function createEngine({
1338
2289
  if (hit?.node.id !== handlers._lastHoverId) {
1339
2290
  handlers._lastHoverId = hit?.node.id;
1340
2291
  handlers.onHover?.(hit?.node);
2292
+ constellationLayer.setHovered(hit?.node.id ?? null);
1341
2293
  }
1342
2294
  document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
1343
2295
  }
1344
2296
  }
1345
2297
  function onMouseUp(e) {
2298
+ const dx = e.clientX - state.lastMouseX;
2299
+ const dy = e.clientY - state.lastMouseY;
2300
+ const movedDist = Math.sqrt(dx * dx + dy * dy);
1346
2301
  if (state.dragMode === "node") {
1347
2302
  const fullArr = getFullArrangement();
1348
2303
  handlers.onArrangementChange?.(fullArr);
@@ -1355,38 +2310,69 @@ function createEngine({
1355
2310
  state.isDragging = false;
1356
2311
  state.dragMode = "none";
1357
2312
  document.body.style.cursor = "default";
2313
+ if (movedDist < 5) {
2314
+ const hit = pick(e);
2315
+ if (hit) {
2316
+ handlers.onSelect?.(hit.node);
2317
+ constellationLayer.setFocused(hit.node.id);
2318
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2319
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2320
+ } else {
2321
+ setFocusedBook(null);
2322
+ }
2323
+ }
1358
2324
  } else {
1359
2325
  const hit = pick(e);
1360
- if (hit) handlers.onSelect?.(hit.node);
2326
+ if (hit) {
2327
+ handlers.onSelect?.(hit.node);
2328
+ constellationLayer.setFocused(hit.node.id);
2329
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2330
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2331
+ } else {
2332
+ setFocusedBook(null);
2333
+ }
1361
2334
  }
1362
2335
  }
1363
2336
  function onWheel(e) {
1364
2337
  e.preventDefault();
2338
+ flyToActive = false;
1365
2339
  const aspect = container.clientWidth / container.clientHeight;
1366
2340
  renderer.domElement.getBoundingClientRect();
1367
2341
  const vBefore = getMouseViewVector(state.fov, aspect);
1368
2342
  const zoomSpeed = 1e-3 * state.fov;
1369
2343
  state.fov += e.deltaY * zoomSpeed;
1370
2344
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2345
+ handlers.onFovChange?.(state.fov);
1371
2346
  updateUniforms();
1372
2347
  const vAfter = getMouseViewVector(state.fov, aspect);
1373
- const quaternion = new THREE4__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
2348
+ const quaternion = new THREE5__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
2349
+ const dampStartFov = 40;
2350
+ const dampEndFov = 120;
2351
+ let spinAmount = 1;
2352
+ if (state.fov > dampStartFov) {
2353
+ const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2354
+ spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2355
+ }
2356
+ if (spinAmount < 0.999) {
2357
+ const identityQuat = new THREE5__namespace.Quaternion();
2358
+ quaternion.slerp(identityQuat, 1 - spinAmount);
2359
+ }
1374
2360
  const y = Math.sin(state.lat);
1375
2361
  const r = Math.cos(state.lat);
1376
2362
  const x = r * Math.sin(state.lon);
1377
2363
  const z = -r * Math.cos(state.lon);
1378
- const currentLook = new THREE4__namespace.Vector3(x, y, z);
2364
+ const currentLook = new THREE5__namespace.Vector3(x, y, z);
1379
2365
  const camForward = currentLook.clone().normalize();
1380
2366
  const camUp = camera.up.clone();
1381
- const camRight = new THREE4__namespace.Vector3().crossVectors(camForward, camUp).normalize();
1382
- const camUpOrtho = new THREE4__namespace.Vector3().crossVectors(camRight, camForward).normalize();
1383
- const mat = new THREE4__namespace.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
1384
- const qOld = new THREE4__namespace.Quaternion().setFromRotationMatrix(mat);
2367
+ const camRight = new THREE5__namespace.Vector3().crossVectors(camForward, camUp).normalize();
2368
+ const camUpOrtho = new THREE5__namespace.Vector3().crossVectors(camRight, camForward).normalize();
2369
+ const mat = new THREE5__namespace.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
2370
+ const qOld = new THREE5__namespace.Quaternion().setFromRotationMatrix(mat);
1385
2371
  const qNew = qOld.clone().multiply(quaternion);
1386
- const newForward = new THREE4__namespace.Vector3(0, 0, -1).applyQuaternion(qNew);
2372
+ const newForward = new THREE5__namespace.Vector3(0, 0, -1).applyQuaternion(qNew);
1387
2373
  state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
1388
2374
  state.lon = Math.atan2(newForward.x, -newForward.z);
1389
- const newUp = new THREE4__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
2375
+ const newUp = new THREE5__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
1390
2376
  camera.up.copy(newUp);
1391
2377
  if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
1392
2378
  const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
@@ -1419,67 +2405,144 @@ function createEngine({
1419
2405
  el.addEventListener("mouseenter", () => {
1420
2406
  isMouseInWindow = true;
1421
2407
  });
1422
- el.addEventListener("mouseleave", () => {
1423
- isMouseInWindow = false;
1424
- });
2408
+ el.addEventListener("mouseleave", onWindowBlur);
2409
+ window.addEventListener("blur", onWindowBlur);
1425
2410
  raf = requestAnimationFrame(tick);
1426
2411
  }
1427
2412
  function tick() {
1428
2413
  if (!running) return;
1429
2414
  raf = requestAnimationFrame(tick);
1430
- if (!state.isDragging && isMouseInWindow) {
2415
+ const now = performance.now();
2416
+ globalUniforms.uTime.value = now / 1e3;
2417
+ let activeId = null;
2418
+ if (focusedBookId) {
2419
+ activeId = focusedBookId;
2420
+ } else if (hoveredBookId) {
2421
+ const lastExit = hoverCooldowns.get(hoveredBookId) || 0;
2422
+ if (now - lastExit > COOLDOWN_MS) {
2423
+ activeId = hoveredBookId;
2424
+ }
2425
+ }
2426
+ const targetStrength = orderRevealEnabled && activeId ? 1 : 0;
2427
+ orderRevealStrength = mix(orderRevealStrength, targetStrength, 0.1);
2428
+ if (orderRevealStrength > 1e-3 || targetStrength > 0) {
2429
+ if (activeId && bookIdToIndex.has(activeId)) {
2430
+ activeBookIndex = bookIdToIndex.get(activeId);
2431
+ }
2432
+ if (starPoints && starPoints.material) {
2433
+ const m = starPoints.material;
2434
+ if (m.uniforms.uActiveBookIndex) m.uniforms.uActiveBookIndex.value = activeBookIndex;
2435
+ if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2436
+ }
2437
+ }
2438
+ const filterTarget = currentFilter ? 1 : 0;
2439
+ filterStrength = mix(filterStrength, filterTarget, 0.1);
2440
+ if (filterStrength > 1e-3 || filterTarget > 0) {
2441
+ if (starPoints && starPoints.material) {
2442
+ const m = starPoints.material;
2443
+ if (m.uniforms.uFilterTestamentIndex) m.uniforms.uFilterTestamentIndex.value = filterTestamentIndex;
2444
+ if (m.uniforms.uFilterDivisionIndex) m.uniforms.uFilterDivisionIndex.value = filterDivisionIndex;
2445
+ if (m.uniforms.uFilterBookIndex) m.uniforms.uFilterBookIndex.value = filterBookIndex;
2446
+ if (m.uniforms.uFilterStrength) m.uniforms.uFilterStrength.value = filterStrength;
2447
+ }
2448
+ }
2449
+ let panX = 0;
2450
+ let panY = 0;
2451
+ if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
1431
2452
  const t = ENGINE_CONFIG.edgePanThreshold;
1432
- const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
1433
- let panX = 0;
1434
- let panY = 0;
1435
- if (mouseNDC.x < -1 + t) {
1436
- const s = (-1 + t - mouseNDC.x) / t;
1437
- panX = -s * s * speedBase;
1438
- } else if (mouseNDC.x > 1 - t) {
1439
- const s = (mouseNDC.x - (1 - t)) / t;
1440
- panX = s * s * speedBase;
1441
- }
1442
- if (mouseNDC.y < -1 + t) {
1443
- const s = (-1 + t - mouseNDC.y) / t;
1444
- panY = -s * s * speedBase;
1445
- } else if (mouseNDC.y > 1 - t) {
1446
- const s = (mouseNDC.y - (1 - t)) / t;
1447
- panY = s * s * speedBase;
1448
- }
1449
- if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
1450
- state.lon += panX;
1451
- state.lat += panY;
1452
- state.targetLon = state.lon;
1453
- state.targetLat = state.lat;
2453
+ const inZoneX = mouseNDC.x < -1 + t || mouseNDC.x > 1 - t;
2454
+ const inZoneY = mouseNDC.y < -1 + t || mouseNDC.y > 1 - t;
2455
+ if (inZoneX || inZoneY) {
2456
+ if (edgeHoverStart === 0) edgeHoverStart = performance.now();
2457
+ if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
2458
+ const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
2459
+ if (mouseNDC.x < -1 + t) {
2460
+ const s = (-1 + t - mouseNDC.x) / t;
2461
+ panX = -s * s * speedBase;
2462
+ } else if (mouseNDC.x > 1 - t) {
2463
+ const s = (mouseNDC.x - (1 - t)) / t;
2464
+ panX = s * s * speedBase;
2465
+ }
2466
+ if (mouseNDC.y < -1 + t) {
2467
+ const s = (-1 + t - mouseNDC.y) / t;
2468
+ panY = -s * s * speedBase;
2469
+ } else if (mouseNDC.y > 1 - t) {
2470
+ const s = (mouseNDC.y - (1 - t)) / t;
2471
+ panY = s * s * speedBase;
2472
+ }
2473
+ }
1454
2474
  } else {
1455
- state.lon += state.velocityX;
1456
- state.lat += state.velocityY;
1457
- state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1458
- state.velocityY *= ENGINE_CONFIG.inertiaDamping;
1459
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
1460
- if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
1461
- }
1462
- } else if (!state.isDragging) {
2475
+ edgeHoverStart = 0;
2476
+ }
2477
+ } else {
2478
+ edgeHoverStart = 0;
2479
+ }
2480
+ if (flyToActive && !state.isDragging) {
2481
+ state.lon = mix(state.lon, flyToTargetLon, FLY_TO_SPEED);
2482
+ state.lat = mix(state.lat, flyToTargetLat, FLY_TO_SPEED);
2483
+ state.fov = mix(state.fov, flyToTargetFov, FLY_TO_SPEED);
2484
+ state.targetLon = state.lon;
2485
+ state.targetLat = state.lat;
2486
+ state.velocityX = 0;
2487
+ state.velocityY = 0;
2488
+ handlers.onFovChange?.(state.fov);
2489
+ if (Math.abs(state.lon - flyToTargetLon) < 1e-4 && Math.abs(state.lat - flyToTargetLat) < 1e-4 && Math.abs(state.fov - flyToTargetFov) < 0.05) {
2490
+ flyToActive = false;
2491
+ state.lon = flyToTargetLon;
2492
+ state.lat = flyToTargetLat;
2493
+ state.fov = flyToTargetFov;
2494
+ }
2495
+ }
2496
+ if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2497
+ state.lon += panX;
2498
+ state.lat += panY;
2499
+ state.targetLon = state.lon;
2500
+ state.targetLat = state.lat;
2501
+ } else if (!state.isDragging && !flyToActive) {
1463
2502
  state.lon += state.velocityX;
1464
2503
  state.lat += state.velocityY;
1465
2504
  state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1466
2505
  state.velocityY *= ENGINE_CONFIG.inertiaDamping;
2506
+ if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2507
+ if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
1467
2508
  }
1468
2509
  state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
1469
2510
  const y = Math.sin(state.lat);
1470
2511
  const r = Math.cos(state.lat);
1471
2512
  const x = r * Math.sin(state.lon);
1472
2513
  const z = -r * Math.cos(state.lon);
1473
- const target = new THREE4__namespace.Vector3(x, y, z);
1474
- const idealUp = new THREE4__namespace.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
2514
+ const target = new THREE5__namespace.Vector3(x, y, z);
2515
+ const idealUp = new THREE5__namespace.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
1475
2516
  camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
1476
2517
  camera.up.normalize();
1477
2518
  camera.lookAt(target);
2519
+ camera.updateMatrixWorld();
2520
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
1478
2521
  updateUniforms();
2522
+ const nowSec = now / 1e3;
2523
+ const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2524
+ lastTickTime = nowSec;
2525
+ linesFader.target = currentConfig?.showConstellationLines ?? false;
2526
+ linesFader.update(dt);
2527
+ artFader.target = currentConfig?.showConstellationArt ?? false;
2528
+ artFader.update(dt);
2529
+ constellationLayer.update(state.fov, artFader.eased > 0.01);
2530
+ if (artFader.eased < 1) {
2531
+ constellationLayer.setGlobalOpacity?.(artFader.eased);
2532
+ }
2533
+ backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2534
+ if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
1479
2535
  const DIVISION_THRESHOLD = 60;
1480
2536
  const showDivisions = state.fov > DIVISION_THRESHOLD;
1481
2537
  if (constellationLines) {
1482
- constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2538
+ constellationLines.visible = linesFader.eased > 0.01;
2539
+ if (constellationLines.visible && constellationLines.material) {
2540
+ const mat = constellationLines.material;
2541
+ if (mat.uniforms?.color) {
2542
+ mat.uniforms.color.value.setHex(11193599);
2543
+ mat.opacity = linesFader.eased;
2544
+ }
2545
+ }
1483
2546
  }
1484
2547
  if (boundaryLines) {
1485
2548
  boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
@@ -1499,7 +2562,8 @@ function createEngine({
1499
2562
  const showBookLabels = currentConfig?.showBookLabels === true;
1500
2563
  const showDivisionLabels = currentConfig?.showDivisionLabels === true;
1501
2564
  const showChapterLabels = currentConfig?.showChapterLabels === true;
1502
- const showChapters = state.fov < 35;
2565
+ const showGroupLabels = currentConfig?.showGroupLabels === true;
2566
+ const showChapters = state.fov < 45;
1503
2567
  for (const item of dynamicLabels) {
1504
2568
  const uniforms = item.obj.material.uniforms;
1505
2569
  const level = item.node.level;
@@ -1507,20 +2571,21 @@ function createEngine({
1507
2571
  if (level === 2 && showBookLabels) isEnabled = true;
1508
2572
  else if (level === 1 && showDivisionLabels) isEnabled = true;
1509
2573
  else if (level === 3 && showChapterLabels) isEnabled = true;
2574
+ else if (level === 2.5 && showGroupLabels) isEnabled = true;
1510
2575
  if (!isEnabled) {
1511
- uniforms.uAlpha.value = THREE4__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2576
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1512
2577
  item.obj.visible = uniforms.uAlpha.value > 0.01;
1513
2578
  continue;
1514
2579
  }
1515
2580
  const pWorld = item.obj.position;
1516
2581
  const pProj = smartProjectJS(pWorld);
1517
2582
  if (pProj.z > 0.2) {
1518
- uniforms.uAlpha.value = THREE4__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2583
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1519
2584
  item.obj.visible = uniforms.uAlpha.value > 0.01;
1520
2585
  continue;
1521
2586
  }
1522
- if (level === 3 && !showChapters && item.node.id !== state.draggedNodeId) {
1523
- uniforms.uAlpha.value = THREE4__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2587
+ if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2588
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1524
2589
  item.obj.visible = uniforms.uAlpha.value > 0.01;
1525
2590
  continue;
1526
2591
  }
@@ -1531,7 +2596,7 @@ function createEngine({
1531
2596
  const size = uniforms.uSize.value;
1532
2597
  const pixelH = size.y * screenH * 0.8;
1533
2598
  const pixelW = size.x * screenH * 0.8;
1534
- labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level });
2599
+ labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level, ndcX, ndcY });
1535
2600
  }
1536
2601
  const hoverId = handlers._lastHoverId;
1537
2602
  const selectedId = state.draggedNodeId;
@@ -1551,17 +2616,19 @@ function createEngine({
1551
2616
  const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
1552
2617
  if (l.level === 1) {
1553
2618
  let rot = 0;
1554
- const blend = globalUniforms.uBlend.value;
1555
- if (blend > 0.5) {
2619
+ const isWideAngle = currentProjection.id !== "perspective";
2620
+ if (isWideAngle) {
1556
2621
  const dx = l.sX - screenW / 2;
1557
2622
  const dy = l.sY - screenH / 2;
1558
2623
  rot = Math.atan2(-dy, -dx) - Math.PI / 2;
1559
2624
  }
1560
- l.uniforms.uAngle.value = THREE4__namespace.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2625
+ l.uniforms.uAngle.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
1561
2626
  }
1562
2627
  if (l.level === 2) {
1563
- target2 = 1;
1564
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2628
+ {
2629
+ target2 = 1;
2630
+ occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2631
+ }
1565
2632
  } else if (l.level === 1) {
1566
2633
  if (showDivisions || isSpecial) {
1567
2634
  const pad = -5;
@@ -1570,12 +2637,28 @@ function createEngine({
1570
2637
  occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
1571
2638
  }
1572
2639
  }
1573
- } else if (l.level === 3) {
2640
+ } else if (l.level === 2.5 || l.level === 3) {
1574
2641
  if (showChapters || isSpecial) {
1575
2642
  target2 = 1;
2643
+ if (!isSpecial) {
2644
+ const dist = Math.sqrt(l.ndcX * l.ndcX + l.ndcY * l.ndcY);
2645
+ const focusFade = 1 - THREE5__namespace.MathUtils.smoothstep(0.4, 0.7, dist);
2646
+ target2 *= focusFade;
2647
+ }
2648
+ }
2649
+ }
2650
+ if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2651
+ const node = l.item.node;
2652
+ if (node.level === 3) {
2653
+ target2 = 0;
2654
+ } else if (node.level === 2 || node.level === 2.5) {
2655
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2656
+ if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2657
+ target2 = 0;
2658
+ }
1576
2659
  }
1577
2660
  }
1578
- l.uniforms.uAlpha.value = THREE4__namespace.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2661
+ l.uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
1579
2662
  l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
1580
2663
  }
1581
2664
  renderer.render(scene, camera);
@@ -1589,41 +2672,96 @@ function createEngine({
1589
2672
  window.removeEventListener("mousemove", onMouseMove);
1590
2673
  window.removeEventListener("mouseup", onMouseUp);
1591
2674
  el.removeEventListener("wheel", onWheel);
2675
+ el.removeEventListener("mouseleave", onWindowBlur);
2676
+ window.removeEventListener("blur", onWindowBlur);
1592
2677
  }
1593
2678
  function dispose() {
1594
2679
  stop();
2680
+ constellationLayer.dispose();
1595
2681
  renderer.dispose();
1596
2682
  renderer.domElement.remove();
1597
2683
  }
1598
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement };
2684
+ function setHoveredBook(id) {
2685
+ if (id === hoveredBookId) return;
2686
+ if (hoveredBookId) {
2687
+ hoverCooldowns.set(hoveredBookId, performance.now());
2688
+ }
2689
+ hoveredBookId = id;
2690
+ }
2691
+ function setFocusedBook(id) {
2692
+ focusedBookId = id;
2693
+ }
2694
+ function setOrderRevealEnabled(enabled) {
2695
+ orderRevealEnabled = enabled;
2696
+ }
2697
+ function flyTo(nodeId, targetFov) {
2698
+ const node = nodeById.get(nodeId);
2699
+ if (!node) return;
2700
+ const pos = getPosition(node).normalize();
2701
+ flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2702
+ flyToTargetLon = Math.atan2(pos.x, -pos.z);
2703
+ flyToTargetFov = targetFov ?? ENGINE_CONFIG.minFov;
2704
+ flyToActive = true;
2705
+ state.velocityX = 0;
2706
+ state.velocityY = 0;
2707
+ }
2708
+ function setHierarchyFilter(filter) {
2709
+ currentFilter = filter;
2710
+ if (filter) {
2711
+ filterTestamentIndex = filter.testament && testamentToIndex.has(filter.testament) ? testamentToIndex.get(filter.testament) : -1;
2712
+ filterDivisionIndex = filter.division && divisionToIndex.has(filter.division) ? divisionToIndex.get(filter.division) : -1;
2713
+ filterBookIndex = filter.bookKey && bookIdToIndex.has(`B:${filter.bookKey}`) ? bookIdToIndex.get(`B:${filter.bookKey}`) : -1;
2714
+ } else {
2715
+ filterTestamentIndex = -1;
2716
+ filterDivisionIndex = -1;
2717
+ filterBookIndex = -1;
2718
+ }
2719
+ }
2720
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
1599
2721
  }
1600
- var ENGINE_CONFIG;
2722
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
1601
2723
  var init_createEngine = __esm({
1602
2724
  "src/engine/createEngine.ts"() {
1603
2725
  init_layout();
1604
2726
  init_materials();
2727
+ init_ConstellationArtworkLayer();
2728
+ init_projections();
2729
+ init_fader();
1605
2730
  ENGINE_CONFIG = {
1606
2731
  minFov: 10,
1607
- maxFov: 165,
1608
- defaultFov: 80,
2732
+ maxFov: 135,
2733
+ defaultFov: 50,
1609
2734
  dragSpeed: 125e-5,
1610
2735
  inertiaDamping: 0.92,
1611
- blendStart: 60,
1612
- blendEnd: 165,
1613
- zenithStartFov: 110,
1614
- zenithStrength: 0.02,
2736
+ blendStart: 35,
2737
+ blendEnd: 83,
2738
+ zenithStartFov: 75,
2739
+ zenithStrength: 0.15,
1615
2740
  horizonLockStrength: 0.05,
1616
2741
  edgePanThreshold: 0.15,
1617
- edgePanMaxSpeed: 0.02
2742
+ edgePanMaxSpeed: 0.02,
2743
+ edgePanDelay: 250
2744
+ };
2745
+ ORDER_REVEAL_CONFIG = {
2746
+ globalDim: 0.85,
2747
+ pulseAmplitude: 0.6,
2748
+ pulseDuration: 2,
2749
+ delayPerChapter: 0.1
1618
2750
  };
1619
2751
  }
1620
2752
  });
1621
2753
  var StarMap = react.forwardRef(
1622
- ({ config, className, onSelect, onHover, onArrangementChange }, ref) => {
2754
+ ({ config, className, onSelect, onHover, onArrangementChange, onFovChange }, ref) => {
1623
2755
  const containerRef = react.useRef(null);
1624
2756
  const engineRef = react.useRef(null);
1625
2757
  react.useImperativeHandle(ref, () => ({
1626
- getFullArrangement: () => engineRef.current?.getFullArrangement?.()
2758
+ getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2759
+ setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2760
+ setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2761
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled),
2762
+ setHierarchyFilter: (filter) => engineRef.current?.setHierarchyFilter?.(filter),
2763
+ flyTo: (nodeId, targetFov) => engineRef.current?.flyTo?.(nodeId, targetFov),
2764
+ setProjection: (id) => engineRef.current?.setProjection?.(id)
1627
2765
  }));
1628
2766
  react.useEffect(() => {
1629
2767
  let disposed = false;
@@ -1635,7 +2773,8 @@ var StarMap = react.forwardRef(
1635
2773
  container: containerRef.current,
1636
2774
  onSelect,
1637
2775
  onHover,
1638
- onArrangementChange
2776
+ onArrangementChange,
2777
+ onFovChange
1639
2778
  });
1640
2779
  engineRef.current.setConfig(config);
1641
2780
  engineRef.current.start();
@@ -1651,8 +2790,8 @@ var StarMap = react.forwardRef(
1651
2790
  engineRef.current?.setConfig?.(config);
1652
2791
  }, [config]);
1653
2792
  react.useEffect(() => {
1654
- engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange });
1655
- }, [onSelect, onHover, onArrangementChange]);
2793
+ engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange, onFovChange });
2794
+ }, [onSelect, onHover, onArrangementChange, onFovChange]);
1656
2795
  return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: containerRef, className, style: { width: "100%", height: "100%" } });
1657
2796
  }
1658
2797
  );
@@ -1661,7 +2800,6 @@ var StarMap = react.forwardRef(
1661
2800
  function bibleToSceneModel(data) {
1662
2801
  const nodes = [];
1663
2802
  const links = [];
1664
- let bookCounter = 0;
1665
2803
  const id = {
1666
2804
  testament: (t) => `T:${t}`,
1667
2805
  division: (t, d) => `D:${t}:${d}`,
@@ -1682,8 +2820,7 @@ function bibleToSceneModel(data) {
1682
2820
  });
1683
2821
  links.push({ source: did, target: tid });
1684
2822
  for (const b of d.books) {
1685
- bookCounter++;
1686
- const bookLabel = `${bookCounter}. ${b.name}`;
2823
+ const bookLabel = b.name;
1687
2824
  const bid = id.book(b.key);
1688
2825
  nodes.push({
1689
2826
  id: bid,
@@ -30796,7 +31933,7 @@ var RNG = class {
30796
31933
  const r = Math.sqrt(1 - y * y);
30797
31934
  const x = r * Math.cos(theta);
30798
31935
  const z = r * Math.sin(theta);
30799
- return new THREE4__namespace.Vector3(x, y, z);
31936
+ return new THREE5__namespace.Vector3(x, y, z);
30800
31937
  }
30801
31938
  };
30802
31939
  function simpleNoise3D(v, scale) {
@@ -30834,11 +31971,11 @@ function generateArrangement(bible, options = {}) {
30834
31971
  });
30835
31972
  });
30836
31973
  const bookCount = books.length;
30837
- const mwRad = THREE4__namespace.MathUtils.degToRad(opts.milkyWayAngle);
30838
- const mwNormal = new THREE4__namespace.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
31974
+ const mwRad = THREE5__namespace.MathUtils.degToRad(opts.milkyWayAngle);
31975
+ const mwNormal = new THREE5__namespace.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
30839
31976
  const anchors = [];
30840
31977
  for (let i = 0; i < bookCount; i++) {
30841
- let bestP = new THREE4__namespace.Vector3();
31978
+ let bestP = new THREE5__namespace.Vector3();
30842
31979
  let valid = false;
30843
31980
  let attempt = 0;
30844
31981
  while (!valid && attempt < 100) {
@@ -30864,7 +32001,7 @@ function generateArrangement(bible, options = {}) {
30864
32001
  arrangement[`B:${book.key}`] = { position: [anchorPos.x, anchorPos.y, anchorPos.z] };
30865
32002
  for (let c = 0; c < book.chapters; c++) {
30866
32003
  const localSpread = opts.clusterSpread * (0.8 + rng.next() * 0.4);
30867
- const offset = new THREE4__namespace.Vector3(
32004
+ const offset = new THREE5__namespace.Vector3(
30868
32005
  (rng.next() - 0.5) * 2,
30869
32006
  (rng.next() - 0.5) * 2,
30870
32007
  (rng.next() - 0.5) * 2
@@ -30885,7 +32022,7 @@ function generateArrangement(bible, options = {}) {
30885
32022
  const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
30886
32023
  const divId = `D:${book.testament}:${book.division}`;
30887
32024
  if (!divisions.has(divId)) {
30888
- divisions.set(divId, { sum: new THREE4__namespace.Vector3(), count: 0 });
32025
+ divisions.set(divId, { sum: new THREE5__namespace.Vector3(), count: 0 });
30889
32026
  }
30890
32027
  const entry = divisions.get(divId);
30891
32028
  entry.sum.add(anchorPos);
@@ -30901,6 +32038,9 @@ function generateArrangement(bible, options = {}) {
30901
32038
  return arrangement;
30902
32039
  }
30903
32040
 
32041
+ // src/index.ts
32042
+ init_projections();
32043
+
30904
32044
  exports.StarMap = StarMap;
30905
32045
  exports.bibleToSceneModel = bibleToSceneModel;
30906
32046
  exports.defaultGenerateOptions = defaultGenerateOptions;