@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE4 from 'three';
1
+ import * as THREE5 from 'three';
2
2
  import { forwardRef, useRef, useImperativeHandle, useEffect } from 'react';
3
3
  import { jsx } from 'react/jsx-runtime';
4
4
 
@@ -129,14 +129,14 @@ var init_constellations = __esm({
129
129
  });
130
130
  function lookAt(point, target, up) {
131
131
  const zAxis = target.clone().normalize();
132
- let xAxis = new THREE4.Vector3().crossVectors(up, zAxis);
132
+ let xAxis = new THREE5.Vector3().crossVectors(up, zAxis);
133
133
  if (xAxis.lengthSq() < 1e-4) {
134
- xAxis = new THREE4.Vector3().crossVectors(new THREE4.Vector3(1, 0, 0), zAxis);
134
+ xAxis = new THREE5.Vector3().crossVectors(new THREE5.Vector3(1, 0, 0), zAxis);
135
135
  }
136
136
  xAxis.normalize();
137
- const yAxis = new THREE4.Vector3().crossVectors(zAxis, xAxis).normalize();
138
- const m = new THREE4.Matrix4().makeBasis(xAxis, yAxis, zAxis);
139
- const v = new THREE4.Vector3(point.x, point.y, point.z);
137
+ const yAxis = new THREE5.Vector3().crossVectors(zAxis, xAxis).normalize();
138
+ const m = new THREE5.Matrix4().makeBasis(xAxis, yAxis, zAxis);
139
+ const v = new THREE5.Vector3(point.x, point.y, point.z);
140
140
  v.applyMatrix4(m);
141
141
  v.add(target);
142
142
  return { x: v.x, y: v.y, z: v.z };
@@ -217,7 +217,7 @@ function computeLayoutPositions(model, layout) {
217
217
  const radiusAtY = Math.sqrt(1 - y * y);
218
218
  const x = Math.cos(midAngle) * radiusAtY;
219
219
  const z = Math.sin(midAngle) * radiusAtY;
220
- const labelPos = new THREE4.Vector3(x, y, z).multiplyScalar(radius);
220
+ const labelPos = new THREE5.Vector3(x, y, z).multiplyScalar(radius);
221
221
  uDivision.meta.x = labelPos.x;
222
222
  uDivision.meta.y = labelPos.y;
223
223
  uDivision.meta.z = labelPos.z;
@@ -233,7 +233,7 @@ function computeLayoutPositions(model, layout) {
233
233
  const theta = startAngle + t * angleSpan;
234
234
  const x = Math.cos(theta) * radiusAtY;
235
235
  const z = Math.sin(theta) * radiusAtY;
236
- const bookPos = new THREE4.Vector3(x, y, z).multiplyScalar(radius);
236
+ const bookPos = new THREE5.Vector3(x, y, z).multiplyScalar(radius);
237
237
  const labelPos = bookPos.clone();
238
238
  labelPos.y += radius * 0.025;
239
239
  labelPos.setLength(radius);
@@ -244,7 +244,7 @@ function computeLayoutPositions(model, layout) {
244
244
  if (chapters.length > 0) {
245
245
  const territoryRadius = radius * 2 / Math.sqrt(books.length * 2) * 0.7;
246
246
  const localPoints = getConstellationLayout(bookKey, chapters.length, territoryRadius);
247
- const up = new THREE4.Vector3(0, 1, 0);
247
+ const up = new THREE5.Vector3(0, 1, 0);
248
248
  chapters.forEach((chap, idx) => {
249
249
  const uChap = updatedNodeMap.get(chap.id);
250
250
  const lp = localPoints[idx];
@@ -263,10 +263,10 @@ function computeLayoutPositions(model, layout) {
263
263
  testaments.forEach((t) => {
264
264
  const children = childrenMap.get(t.id) ?? [];
265
265
  if (children.length === 0) return;
266
- const centroid = new THREE4.Vector3();
266
+ const centroid = new THREE5.Vector3();
267
267
  children.forEach((c) => {
268
268
  const u = updatedNodeMap.get(c.id);
269
- centroid.add(new THREE4.Vector3(u.meta.x, u.meta.y, u.meta.z));
269
+ centroid.add(new THREE5.Vector3(u.meta.x, u.meta.y, u.meta.z));
270
270
  });
271
271
  centroid.divideScalar(children.length);
272
272
  if (centroid.length() > 0.1) {
@@ -319,45 +319,67 @@ var init_shaders = __esm({
319
319
  uniform float uScale;
320
320
  uniform float uAspect;
321
321
  uniform float uBlend;
322
+ uniform int uProjectionType;
322
323
 
323
324
  vec4 smartProject(vec4 viewPos) {
324
325
  vec3 dir = normalize(viewPos.xyz);
325
326
  float dist = length(viewPos.xyz);
326
- float zLinear = max(0.01, -dir.z);
327
- float kStereo = 2.0 / (1.0 - dir.z);
328
- float kLinear = 1.0 / zLinear;
329
- float k = mix(kLinear, kStereo, uBlend);
327
+ float k;
328
+
329
+ // Radial Clipping: Push clipped points off-screen in their natural direction
330
+ // to prevent lines "darting" across the center.
331
+ vec2 escapeDir = (length(dir.xy) > 0.0001) ? normalize(dir.xy) : vec2(1.0, 1.0);
332
+ vec2 escapePos = escapeDir * 10000.0;
333
+
334
+ if (uProjectionType == 0) {
335
+ // Perspective
336
+ if (dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
337
+ k = 1.0 / max(0.01, -dir.z);
338
+ } else if (uProjectionType == 1) {
339
+ // Stereographic \u2014 tighter clip to prevent stretch near singularity
340
+ if (dir.z > 0.1) return vec4(escapePos, 10.0, 1.0);
341
+ k = 2.0 / (1.0 - dir.z);
342
+ } else {
343
+ // Blended (auto-blend behavior)
344
+ float zLinear = max(0.01, -dir.z);
345
+ float kStereo = 2.0 / (1.0 - dir.z);
346
+ float kLinear = 1.0 / zLinear;
347
+ k = mix(kLinear, kStereo, uBlend);
348
+
349
+ // Tighter clip threshold that scales with blend factor
350
+ float clipZ = mix(-0.1, 0.1, uBlend);
351
+ if (dir.z > clipZ) return vec4(escapePos, 10.0, 1.0);
352
+ }
353
+
330
354
  vec2 projected = vec2(k * dir.x, k * dir.y);
331
355
  projected *= uScale;
332
356
  projected.x /= uAspect;
333
- float zMetric = -1.0 + (dist / 2000.0);
334
- // Clip backward facing points in fisheye mode
335
- if (uBlend > 0.5 && dir.z > 0.4) return vec4(10.0, 10.0, 10.0, 1.0);
336
- // Clip very close points in linear mode
337
- if (uBlend < 0.1 && dir.z > -0.1) return vec4(10.0, 10.0, 10.0, 1.0);
357
+ float zMetric = -1.0 + (dist / 15000.0);
358
+
338
359
  return vec4(projected, zMetric, 1.0);
339
360
  }
340
361
  `;
341
362
  MASK_CHUNK = `
342
363
  uniform float uAspect;
343
364
  uniform float uBlend;
365
+ uniform int uProjectionType;
344
366
  varying vec2 vScreenPos;
345
367
  float getMaskAlpha() {
346
- if (uBlend < 0.1) return 1.0;
368
+ // No artificial circular mask \u2014 the horizon, atmosphere, and ground
369
+ // define the dome boundary naturally (as Stellarium does).
370
+ // Only apply a minimal edge softening to catch stray back-face artifacts.
347
371
  vec2 p = vScreenPos;
348
372
  p.x *= uAspect;
349
373
  float dist = length(p);
350
- float t = smoothstep(0.75, 1.0, uBlend);
351
- float currentRadius = mix(2.5, 1.0, t);
352
- float edgeSoftness = mix(0.5, 0.02, t);
353
- return 1.0 - smoothstep(currentRadius - edgeSoftness, currentRadius, dist);
374
+ // Gentle falloff only at extreme screen edges (beyond NDC ~1.8)
375
+ return 1.0 - smoothstep(1.8, 2.0, dist);
354
376
  }
355
377
  `;
356
378
  }
357
379
  });
358
380
  function createSmartMaterial(params) {
359
381
  const uniforms = { ...globalUniforms, ...params.uniforms };
360
- return new THREE4.ShaderMaterial({
382
+ return new THREE5.ShaderMaterial({
361
383
  uniforms,
362
384
  vertexShader: `
363
385
  ${BLEND_CHUNK}
@@ -371,8 +393,8 @@ function createSmartMaterial(params) {
371
393
  transparent: params.transparent || false,
372
394
  depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
373
395
  depthTest: params.depthTest !== void 0 ? params.depthTest : true,
374
- side: params.side || THREE4.FrontSide,
375
- blending: params.blending || THREE4.NormalBlending
396
+ side: params.side || THREE5.FrontSide,
397
+ blending: params.blending || THREE5.NormalBlending
376
398
  });
377
399
  }
378
400
  var globalUniforms;
@@ -382,7 +404,412 @@ var init_materials = __esm({
382
404
  globalUniforms = {
383
405
  uScale: { value: 1 },
384
406
  uAspect: { value: 1 },
385
- uBlend: { value: 0 }
407
+ uBlend: { value: 0 },
408
+ uProjectionType: { value: 2 },
409
+ // 0=perspective, 1=stereographic, 2=blended
410
+ uTime: { value: 0 },
411
+ // Atmosphere Settings
412
+ uAtmGlow: { value: 1 },
413
+ uAtmDark: { value: 0.6 },
414
+ uAtmExtinction: { value: 4 },
415
+ uAtmTwinkle: { value: 0.8 },
416
+ uColorHorizon: { value: new THREE5.Color(3825292) },
417
+ uColorZenith: { value: new THREE5.Color(132104) }
418
+ };
419
+ }
420
+ });
421
+ var ConstellationArtworkLayer;
422
+ var init_ConstellationArtworkLayer = __esm({
423
+ "src/engine/ConstellationArtworkLayer.ts"() {
424
+ init_materials();
425
+ ConstellationArtworkLayer = class {
426
+ root;
427
+ items = [];
428
+ textureLoader = new THREE5.TextureLoader();
429
+ hoveredId = null;
430
+ focusedId = null;
431
+ constructor(root) {
432
+ this.root = new THREE5.Group();
433
+ this.root.renderOrder = -1;
434
+ root.add(this.root);
435
+ }
436
+ getItems() {
437
+ return this.items;
438
+ }
439
+ setPosition(id, pos) {
440
+ const item = this.items.find((i) => i.config.id === id);
441
+ if (item) {
442
+ item.mesh.position.copy(pos);
443
+ }
444
+ }
445
+ load(config, getPosition) {
446
+ this.clear();
447
+ const basePath = config.atlasBasePath.replace(/\/$/, "");
448
+ config.constellations.forEach((c) => {
449
+ let center = new THREE5.Vector3();
450
+ let valid = false;
451
+ let radius = 2e3;
452
+ const arrPos = getPosition(c.id);
453
+ if (arrPos) {
454
+ center.copy(arrPos);
455
+ valid = true;
456
+ if (c.anchors.length > 0) {
457
+ const points = [];
458
+ for (const anchorId of c.anchors) {
459
+ const p = getPosition(anchorId);
460
+ if (p) points.push(p);
461
+ }
462
+ if (points.length > 0) {
463
+ radius = points[0].length();
464
+ }
465
+ }
466
+ } else if (c.center) {
467
+ center.set(c.center[0], c.center[1], c.center[2]);
468
+ valid = true;
469
+ } else if (c.anchors.length > 0) {
470
+ const points = [];
471
+ for (const anchorId of c.anchors) {
472
+ const p = getPosition(anchorId);
473
+ if (p) points.push(p);
474
+ }
475
+ if (points.length > 0) {
476
+ for (const p of points) center.add(p);
477
+ center.divideScalar(points.length);
478
+ const len = center.length();
479
+ if (len > 1e-3) {
480
+ radius = points[0].length();
481
+ center.normalize().multiplyScalar(radius);
482
+ }
483
+ valid = true;
484
+ }
485
+ }
486
+ if (!valid) return;
487
+ const normal = center.clone().normalize().negate();
488
+ const upVec = center.clone().normalize();
489
+ let right = new THREE5.Vector3(1, 0, 0);
490
+ if (c.anchors.length >= 2) {
491
+ const p0 = getPosition(c.anchors[0]);
492
+ const p1 = getPosition(c.anchors[1]);
493
+ if (p0 && p1) {
494
+ const diff = new THREE5.Vector3().subVectors(p1, p0);
495
+ right.copy(diff).sub(upVec.clone().multiplyScalar(diff.dot(upVec))).normalize();
496
+ }
497
+ } else {
498
+ if (Math.abs(upVec.y) > 0.9) right.set(1, 0, 0).cross(upVec).normalize();
499
+ else right.set(0, 1, 0).cross(upVec).normalize();
500
+ }
501
+ const top = new THREE5.Vector3().crossVectors(upVec, right).normalize();
502
+ right.crossVectors(top, upVec).normalize();
503
+ new THREE5.Matrix4().makeBasis(right, top, normal);
504
+ const geometry = new THREE5.PlaneGeometry(1, 1);
505
+ let size = c.radius;
506
+ if (size <= 1) size *= radius;
507
+ size *= 2;
508
+ const texPath = `${basePath}/${c.image}`;
509
+ let blending = THREE5.NormalBlending;
510
+ if (c.blend === "additive") blending = THREE5.AdditiveBlending;
511
+ const material = createSmartMaterial({
512
+ uniforms: {
513
+ uMap: { value: this.textureLoader.load(texPath) },
514
+ // Placeholder, updated below
515
+ uOpacity: { value: c.opacity },
516
+ uSize: { value: size },
517
+ uImgRotation: { value: THREE5.MathUtils.degToRad(c.rotationDeg) },
518
+ uImgAspect: { value: c.aspectRatio ?? 1 }
519
+ // uScale, uAspect (screen) are injected by createSmartMaterial/globalUniforms
520
+ },
521
+ vertexShaderBody: `
522
+ uniform float uSize;
523
+ uniform float uImgRotation;
524
+ uniform float uImgAspect;
525
+
526
+ varying vec2 vUv;
527
+
528
+ void main() {
529
+ vUv = uv;
530
+
531
+ // 1. Project Center Point (Proven Method)
532
+ vec4 mvCenter = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
533
+ vec4 clipCenter = smartProject(mvCenter);
534
+
535
+ // 2. Project "Up" Point (World Zenith)
536
+ // Transform World Up (0,1,0) to View Space
537
+ vec3 viewUpDir = mat3(viewMatrix) * vec3(0.0, 1.0, 0.0);
538
+ // Offset center by a significant amount (1000.0) to ensure screen delta
539
+ vec4 mvUp = mvCenter + vec4(viewUpDir * 1000.0, 0.0);
540
+ vec4 clipUp = smartProject(mvUp);
541
+
542
+ // 3. Calculate Horizon Angle
543
+ vec2 screenCenter = clipCenter.xy / clipCenter.w;
544
+ vec2 screenUp = clipUp.xy / clipUp.w;
545
+ vec2 screenDelta = screenUp - screenCenter;
546
+
547
+ float horizonAngle = 0.0;
548
+ if (length(screenDelta) > 0.001) {
549
+ vec2 screenDir = normalize(screenDelta);
550
+ horizonAngle = atan(screenDir.y, screenDir.x) - 1.5708; // -90 deg
551
+ }
552
+
553
+ // 4. Combine with User Rotation
554
+ float finalAngle = uImgRotation + horizonAngle;
555
+
556
+ // 5. Billboard Offset
557
+ vec2 offset = position.xy;
558
+
559
+ float cr = cos(finalAngle);
560
+ float sr = sin(finalAngle);
561
+ vec2 rotated = vec2(
562
+ offset.x * cr - offset.y * sr,
563
+ offset.x * sr + offset.y * cr
564
+ );
565
+
566
+ rotated.x *= uImgAspect;
567
+
568
+ float dist = length(mvCenter.xyz);
569
+ float scale = (uSize / dist) * uScale;
570
+
571
+ rotated *= scale;
572
+ rotated.x /= uAspect;
573
+
574
+ gl_Position = clipCenter;
575
+ gl_Position.xy += rotated * clipCenter.w;
576
+
577
+ vScreenPos = gl_Position.xy / gl_Position.w;
578
+ }
579
+ `,
580
+ fragmentShader: `
581
+ uniform sampler2D uMap;
582
+ uniform float uOpacity;
583
+ varying vec2 vUv;
584
+ void main() {
585
+ float mask = getMaskAlpha();
586
+ if (mask < 0.01) discard;
587
+ vec4 tex = texture2D(uMap, vUv);
588
+ gl_FragColor = vec4(tex.rgb, tex.a * uOpacity * mask);
589
+ }
590
+ `,
591
+ transparent: true,
592
+ depthWrite: false,
593
+ depthTest: true,
594
+ blending,
595
+ side: THREE5.DoubleSide
596
+ });
597
+ material.uniforms.uMap.value = this.textureLoader.load(texPath, (tex) => {
598
+ if (c.aspectRatio === void 0 && tex.image.width && tex.image.height) {
599
+ const natAspect = tex.image.width / tex.image.height;
600
+ material.uniforms.uImgAspect.value = natAspect;
601
+ }
602
+ });
603
+ if (c.zBias) {
604
+ material.polygonOffset = true;
605
+ material.polygonOffsetFactor = -c.zBias;
606
+ }
607
+ const mesh = new THREE5.Mesh(geometry, material);
608
+ mesh.frustumCulled = false;
609
+ mesh.userData = { id: c.id, type: "constellation" };
610
+ mesh.position.copy(center);
611
+ this.root.add(mesh);
612
+ this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
613
+ });
614
+ }
615
+ _globalOpacity = 1;
616
+ setGlobalOpacity(v) {
617
+ this._globalOpacity = v;
618
+ }
619
+ update(fov, showArt) {
620
+ this.root.visible = showArt;
621
+ if (!showArt) return;
622
+ for (const item of this.items) {
623
+ const { fade } = item.config;
624
+ let opacity = fade.maxOpacity;
625
+ if (fov >= fade.zoomInStart) {
626
+ opacity = fade.maxOpacity;
627
+ } else if (fov <= fade.zoomInEnd) {
628
+ opacity = fade.minOpacity;
629
+ } else {
630
+ const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
631
+ opacity = THREE5.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
632
+ }
633
+ opacity = Math.min(Math.max(opacity, 0), 1) * this._globalOpacity;
634
+ item.material.uniforms.uOpacity.value = opacity;
635
+ }
636
+ }
637
+ setHovered(id) {
638
+ this.hoveredId = id;
639
+ }
640
+ setFocused(id) {
641
+ this.focusedId = id;
642
+ }
643
+ dispose() {
644
+ this.clear();
645
+ this.root.removeFromParent();
646
+ }
647
+ clear() {
648
+ this.items.forEach((i) => {
649
+ this.root.remove(i.mesh);
650
+ i.material.dispose();
651
+ i.mesh.geometry.dispose();
652
+ });
653
+ this.items = [];
654
+ }
655
+ };
656
+ }
657
+ });
658
+
659
+ // src/engine/projections.ts
660
+ var PerspectiveProjection, StereographicProjection, BlendedProjection, PROJECTIONS;
661
+ var init_projections = __esm({
662
+ "src/engine/projections.ts"() {
663
+ PerspectiveProjection = class {
664
+ id = "perspective";
665
+ label = "Perspective";
666
+ maxFov = 160;
667
+ glslProjectionType = 0;
668
+ forward(dir) {
669
+ if (dir.z > -0.1) return null;
670
+ const k = 1 / Math.max(0.01, -dir.z);
671
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
672
+ }
673
+ inverse(uvX, uvY, fovRad) {
674
+ const halfHeight = Math.tan(fovRad / 2);
675
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
676
+ const theta = Math.atan(r * halfHeight);
677
+ const phi = Math.atan2(uvY, uvX);
678
+ const sinT = Math.sin(theta);
679
+ return {
680
+ x: sinT * Math.cos(phi),
681
+ y: sinT * Math.sin(phi),
682
+ z: -Math.cos(theta)
683
+ };
684
+ }
685
+ getScale(fovRad) {
686
+ return 1 / Math.tan(fovRad / 2);
687
+ }
688
+ isClipped(dirZ) {
689
+ return dirZ > -0.1;
690
+ }
691
+ };
692
+ StereographicProjection = class {
693
+ id = "stereographic";
694
+ label = "Stereographic";
695
+ maxFov = 360;
696
+ glslProjectionType = 1;
697
+ forward(dir) {
698
+ if (dir.z > 0.4) return null;
699
+ const k = 2 / (1 - dir.z);
700
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
701
+ }
702
+ inverse(uvX, uvY, fovRad) {
703
+ const halfHeight = 2 * Math.tan(fovRad / 4);
704
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
705
+ const theta = 2 * Math.atan(r * halfHeight / 2);
706
+ const phi = Math.atan2(uvY, uvX);
707
+ const sinT = Math.sin(theta);
708
+ return {
709
+ x: sinT * Math.cos(phi),
710
+ y: sinT * Math.sin(phi),
711
+ z: -Math.cos(theta)
712
+ };
713
+ }
714
+ getScale(fovRad) {
715
+ return 1 / (2 * Math.tan(fovRad / 4));
716
+ }
717
+ isClipped(dirZ) {
718
+ return dirZ > 0.4;
719
+ }
720
+ };
721
+ BlendedProjection = class {
722
+ id = "blended";
723
+ label = "Blended (Auto)";
724
+ maxFov = 165;
725
+ glslProjectionType = 2;
726
+ /** FOV thresholds for blend transition (degrees) */
727
+ blendStart = 40;
728
+ blendEnd = 100;
729
+ /** Current blend factor, updated via setFov() */
730
+ blend = 0;
731
+ /** Call this each frame / when FOV changes so forward/inverse stay in sync */
732
+ setFov(fovDeg) {
733
+ if (fovDeg <= this.blendStart) {
734
+ this.blend = 0;
735
+ return;
736
+ }
737
+ if (fovDeg >= this.blendEnd) {
738
+ this.blend = 1;
739
+ return;
740
+ }
741
+ const t = (fovDeg - this.blendStart) / (this.blendEnd - this.blendStart);
742
+ this.blend = t * t * (3 - 2 * t);
743
+ }
744
+ getBlend() {
745
+ return this.blend;
746
+ }
747
+ forward(dir) {
748
+ if (this.blend > 0.5 && dir.z > 0.4) return null;
749
+ if (this.blend < 0.1 && dir.z > -0.1) return null;
750
+ const kLinear = 1 / Math.max(0.01, -dir.z);
751
+ const kStereo = 2 / (1 - dir.z);
752
+ const k = kLinear * (1 - this.blend) + kStereo * this.blend;
753
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
754
+ }
755
+ inverse(uvX, uvY, fovRad) {
756
+ const r = Math.sqrt(uvX * uvX + uvY * uvY);
757
+ const halfHeightLin = Math.tan(fovRad / 2);
758
+ const thetaLin = Math.atan(r * halfHeightLin);
759
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
760
+ const thetaStereo = 2 * Math.atan(r * halfHeightStereo / 2);
761
+ const theta = thetaLin * (1 - this.blend) + thetaStereo * this.blend;
762
+ const phi = Math.atan2(uvY, uvX);
763
+ const sinT = Math.sin(theta);
764
+ return {
765
+ x: sinT * Math.cos(phi),
766
+ y: sinT * Math.sin(phi),
767
+ z: -Math.cos(theta)
768
+ };
769
+ }
770
+ getScale(fovRad) {
771
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
772
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
773
+ return scaleLinear * (1 - this.blend) + scaleStereo * this.blend;
774
+ }
775
+ isClipped(dirZ) {
776
+ if (this.blend > 0.5) return dirZ > 0.4;
777
+ if (this.blend < 0.1) return dirZ > -0.1;
778
+ return false;
779
+ }
780
+ };
781
+ PROJECTIONS = {
782
+ perspective: () => new PerspectiveProjection(),
783
+ stereographic: () => new StereographicProjection(),
784
+ blended: () => new BlendedProjection()
785
+ };
786
+ }
787
+ });
788
+
789
+ // src/engine/fader.ts
790
+ var Fader;
791
+ var init_fader = __esm({
792
+ "src/engine/fader.ts"() {
793
+ Fader = class {
794
+ target = false;
795
+ value = 0;
796
+ duration;
797
+ constructor(duration = 0.3) {
798
+ this.duration = duration;
799
+ }
800
+ update(dt) {
801
+ const goal = this.target ? 1 : 0;
802
+ if (this.value === goal) return;
803
+ const speed = 1 / this.duration;
804
+ const step = speed * dt;
805
+ const diff = goal - this.value;
806
+ this.value += Math.sign(diff) * Math.min(step, Math.abs(diff));
807
+ }
808
+ /** Smoothstep-eased value for perceptually smooth transitions */
809
+ get eased() {
810
+ const v = this.value;
811
+ return v * v * (3 - 2 * v);
812
+ }
386
813
  };
387
814
  }
388
815
  });
@@ -396,17 +823,49 @@ function createEngine({
396
823
  container,
397
824
  onSelect,
398
825
  onHover,
399
- onArrangementChange
826
+ onArrangementChange,
827
+ onFovChange
400
828
  }) {
401
- const renderer = new THREE4.WebGLRenderer({ antialias: true, alpha: false });
829
+ let hoveredBookId = null;
830
+ let focusedBookId = null;
831
+ let orderRevealEnabled = true;
832
+ let activeBookIndex = -1;
833
+ let orderRevealStrength = 0;
834
+ let flyToActive = false;
835
+ let flyToTargetLon = 0;
836
+ let flyToTargetLat = 0;
837
+ let flyToTargetFov = ENGINE_CONFIG.minFov;
838
+ const FLY_TO_SPEED = 0.04;
839
+ let currentFilter = null;
840
+ let filterStrength = 0;
841
+ let filterTestamentIndex = -1;
842
+ let filterDivisionIndex = -1;
843
+ let filterBookIndex = -1;
844
+ const hoverCooldowns = /* @__PURE__ */ new Map();
845
+ const COOLDOWN_MS = 2e3;
846
+ const bookIdToIndex = /* @__PURE__ */ new Map();
847
+ const testamentToIndex = /* @__PURE__ */ new Map();
848
+ const divisionToIndex = /* @__PURE__ */ new Map();
849
+ const renderer = new THREE5.WebGLRenderer({ antialias: true, alpha: false });
402
850
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
403
851
  renderer.setSize(container.clientWidth, container.clientHeight);
404
852
  container.appendChild(renderer.domElement);
405
- const scene = new THREE4.Scene();
406
- scene.background = new THREE4.Color(0);
407
- const camera = new THREE4.PerspectiveCamera(60, 1, 0.1, 3e3);
853
+ const scene = new THREE5.Scene();
854
+ scene.background = new THREE5.Color(0);
855
+ const camera = new THREE5.PerspectiveCamera(60, 1, 0.1, 1e4);
408
856
  camera.position.set(0, 0, 0);
409
857
  camera.up.set(0, 1, 0);
858
+ function setHoveredBook(id) {
859
+ if (id === hoveredBookId) return;
860
+ const now = performance.now();
861
+ if (hoveredBookId) {
862
+ hoverCooldowns.set(hoveredBookId, now);
863
+ }
864
+ if (id) {
865
+ hoverCooldowns.get(id) || 0;
866
+ }
867
+ hoveredBookId = id;
868
+ }
410
869
  let running = false;
411
870
  let raf = 0;
412
871
  const state = {
@@ -427,247 +886,312 @@ function createEngine({
427
886
  draggedGroup: null,
428
887
  tempArrangement: {}
429
888
  };
430
- const mouseNDC = new THREE4.Vector2();
889
+ const mouseNDC = new THREE5.Vector2();
431
890
  let isMouseInWindow = false;
432
- let handlers = { onSelect, onHover, onArrangementChange };
891
+ let edgeHoverStart = 0;
892
+ let handlers = { onSelect, onHover, onArrangementChange, onFovChange };
433
893
  let currentConfig;
894
+ const constellationLayer = new ConstellationArtworkLayer(scene);
434
895
  function mix(a, b, t) {
435
896
  return a * (1 - t) + b * t;
436
897
  }
437
- function getBlendFactor(fov) {
438
- if (fov <= ENGINE_CONFIG.blendStart) return 0;
439
- if (fov >= ENGINE_CONFIG.blendEnd) return 1;
440
- let t = (fov - ENGINE_CONFIG.blendStart) / (ENGINE_CONFIG.blendEnd - ENGINE_CONFIG.blendStart);
441
- return t * t * (3 - 2 * t);
898
+ let currentProjection = PROJECTIONS.blended();
899
+ function syncProjectionState() {
900
+ if (currentProjection instanceof BlendedProjection) {
901
+ currentProjection.setFov(state.fov);
902
+ globalUniforms.uBlend.value = currentProjection.getBlend();
903
+ }
904
+ globalUniforms.uProjectionType.value = currentProjection.glslProjectionType;
442
905
  }
443
906
  function updateUniforms() {
444
- const blend = getBlendFactor(state.fov);
445
- globalUniforms.uBlend.value = blend;
907
+ syncProjectionState();
446
908
  const fovRad = state.fov * Math.PI / 180;
447
- const scaleLinear = 1 / Math.tan(fovRad / 2);
448
- const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
449
- globalUniforms.uScale.value = mix(scaleLinear, scaleStereo, blend);
450
- globalUniforms.uAspect.value = camera.aspect;
909
+ let scale = currentProjection.getScale(fovRad);
910
+ const aspect = camera.aspect;
911
+ if (currentConfig?.fitProjection) {
912
+ if (aspect > 1) {
913
+ scale /= aspect;
914
+ }
915
+ }
916
+ globalUniforms.uScale.value = scale;
917
+ globalUniforms.uAspect.value = aspect;
451
918
  camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
452
919
  camera.updateProjectionMatrix();
453
920
  }
454
921
  function getMouseViewVector(fovDeg, aspectRatio) {
455
- const blend = getBlendFactor(fovDeg);
922
+ syncProjectionState();
456
923
  const fovRad = fovDeg * Math.PI / 180;
457
924
  const uvX = mouseNDC.x * aspectRatio;
458
925
  const uvY = mouseNDC.y;
459
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
460
- const halfHeightLinear = Math.tan(fovRad / 2);
461
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
462
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
463
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
464
- const theta = mix(theta_lin, theta_str, blend);
465
- const phi = Math.atan2(uvY, uvX);
466
- const sinTheta = Math.sin(theta);
467
- const cosTheta = Math.cos(theta);
468
- return new THREE4.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
926
+ const v = currentProjection.inverse(uvX, uvY, fovRad);
927
+ return new THREE5.Vector3(v.x, v.y, v.z).normalize();
469
928
  }
470
929
  function getMouseWorldVector(pixelX, pixelY, width, height) {
471
930
  const aspect = width / height;
472
931
  const ndcX = pixelX / width * 2 - 1;
473
932
  const ndcY = -(pixelY / height) * 2 + 1;
474
- const blend = getBlendFactor(state.fov);
933
+ syncProjectionState();
475
934
  const fovRad = state.fov * Math.PI / 180;
476
- const uvX = ndcX * aspect;
477
- const uvY = ndcY;
478
- const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
479
- const halfHeightLinear = Math.tan(fovRad / 2);
480
- const theta_lin = Math.atan(r_uv * halfHeightLinear);
481
- const halfHeightStereo = 2 * Math.tan(fovRad / 4);
482
- const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
483
- const theta = mix(theta_lin, theta_str, blend);
484
- const phi = Math.atan2(uvY, uvX);
485
- const sinTheta = Math.sin(theta);
486
- const cosTheta = Math.cos(theta);
487
- const vView = new THREE4.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
935
+ const v = currentProjection.inverse(ndcX * aspect, ndcY, fovRad);
936
+ const vView = new THREE5.Vector3(v.x, v.y, v.z).normalize();
488
937
  return vView.applyQuaternion(camera.quaternion);
489
938
  }
490
939
  function smartProjectJS(worldPos) {
491
940
  const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
492
941
  const dir = viewPos.clone().normalize();
493
- const zLinear = Math.max(0.01, -dir.z);
494
- const kStereo = 2 / (1 - dir.z);
495
- const kLinear = 1 / zLinear;
496
- const blend = globalUniforms.uBlend.value;
497
- const k = mix(kLinear, kStereo, blend);
498
- return { x: k * dir.x, y: k * dir.y, z: dir.z };
942
+ const result = currentProjection.forward(dir);
943
+ if (!result) return { x: 0, y: 0, z: dir.z };
944
+ return result;
499
945
  }
500
- const groundGroup = new THREE4.Group();
946
+ const groundGroup = new THREE5.Group();
501
947
  scene.add(groundGroup);
502
948
  function createGround() {
503
949
  groundGroup.clear();
504
950
  const radius = 995;
505
- const geometry = new THREE4.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2);
951
+ const geometry = new THREE5.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
506
952
  const material = createSmartMaterial({
507
- uniforms: { color: { value: new THREE4.Color(526862) } },
508
- 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; }`,
509
- 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); }`,
510
- side: THREE4.BackSide,
953
+ uniforms: {
954
+ color: { value: new THREE5.Color(65794) },
955
+ fogColor: { value: new THREE5.Color(663098) }
956
+ },
957
+ vertexShaderBody: `
958
+ varying vec3 vPos;
959
+ varying vec3 vWorldPos;
960
+ void main() {
961
+ vPos = position;
962
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
963
+ gl_Position = smartProject(mvPosition);
964
+ vScreenPos = gl_Position.xy / gl_Position.w;
965
+ vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
966
+ }
967
+ `,
968
+ fragmentShader: `
969
+ uniform vec3 color;
970
+ uniform vec3 fogColor;
971
+ varying vec3 vPos;
972
+ varying vec3 vWorldPos;
973
+
974
+ void main() {
975
+ float alphaMask = getMaskAlpha();
976
+ if (alphaMask < 0.01) discard;
977
+
978
+ // Procedural Horizon (Mountains)
979
+ float angle = atan(vPos.z, vPos.x);
980
+
981
+ // FBM-like terrain with increased amplitude
982
+ float h = 0.0;
983
+ h += sin(angle * 6.0) * 35.0;
984
+ h += sin(angle * 13.0 + 1.0) * 18.0;
985
+ h += sin(angle * 29.0 + 2.0) * 8.0;
986
+ h += sin(angle * 63.0 + 4.0) * 3.0;
987
+ h += sin(angle * 97.0 + 5.0) * 1.5;
988
+
989
+ float terrainHeight = h + 12.0;
990
+
991
+ if (vPos.y > terrainHeight) discard;
992
+
993
+ // Atmospheric rim glow just below terrain peaks
994
+ float rimDist = terrainHeight - vPos.y;
995
+ float rim = exp(-rimDist * 0.15) * 0.4;
996
+ vec3 rimColor = fogColor * 1.5;
997
+
998
+ // Atmospheric haze \u2014 stronger near horizon
999
+ float fogFactor = smoothstep(-120.0, terrainHeight, vPos.y);
1000
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.6);
1001
+
1002
+ // Add rim glow near terrain peaks
1003
+ finalCol += rimColor * rim;
1004
+
1005
+ gl_FragColor = vec4(finalCol, 1.0);
1006
+ }
1007
+ `,
1008
+ side: THREE5.BackSide,
511
1009
  transparent: false,
512
1010
  depthWrite: true,
513
1011
  depthTest: true
514
1012
  });
515
- const ground = new THREE4.Mesh(geometry, material);
1013
+ const ground = new THREE5.Mesh(geometry, material);
516
1014
  groundGroup.add(ground);
517
- const boxGeo = new THREE4.BoxGeometry(8, 30, 8);
518
- for (let i = 0; i < 12; i++) {
519
- const angle = i / 12 * Math.PI * 2;
520
- const b = new THREE4.Mesh(boxGeo, material);
521
- const r = radius * 0.98;
522
- b.position.set(Math.cos(angle) * r, -15, Math.sin(angle) * r);
523
- b.lookAt(0, 0, 0);
524
- groundGroup.add(b);
525
- }
526
1015
  }
1016
+ let atmosphereMesh = null;
527
1017
  function createAtmosphere() {
528
- const geometry = new THREE4.SphereGeometry(990, 128, 64);
1018
+ const geometry = new THREE5.SphereGeometry(990, 64, 64);
529
1019
  const material = createSmartMaterial({
530
- uniforms: { top: { value: new THREE4.Color(0) }, bot: { value: new THREE4.Color(1712172) } },
531
1020
  vertexShaderBody: `
532
-
533
- varying vec3 vP;
534
-
535
- void main() {
536
-
537
- vP = position;
538
-
539
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
540
-
541
- gl_Position = smartProject(mv);
542
-
543
- vScreenPos = gl_Position.xy / gl_Position.w;
544
-
545
- }
546
-
547
- `,
1021
+ varying vec3 vWorldNormal;
1022
+ void main() {
1023
+ vWorldNormal = normalize(position);
1024
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
1025
+ gl_Position = smartProject(mv);
1026
+ vScreenPos = gl_Position.xy / gl_Position.w;
1027
+ }`,
548
1028
  fragmentShader: `
549
-
550
- uniform vec3 top;
551
-
552
- uniform vec3 bot;
553
-
554
- varying vec3 vP;
555
-
556
- void main() {
557
-
558
- float alphaMask = getMaskAlpha();
559
-
560
- if (alphaMask < 0.01) discard;
561
-
562
- vec3 n = normalize(vP);
563
-
564
- float h = max(0.0, n.y);
565
-
566
- gl_FragColor = vec4(mix(bot, top, pow(h, 0.6)), 1.0);
567
-
568
- }
569
-
570
- `,
571
- side: THREE4.BackSide,
1029
+ varying vec3 vWorldNormal;
1030
+
1031
+ uniform float uAtmGlow;
1032
+ uniform float uAtmDark;
1033
+ uniform vec3 uColorHorizon;
1034
+ uniform vec3 uColorZenith;
1035
+
1036
+ void main() {
1037
+ float alphaMask = getMaskAlpha();
1038
+ if (alphaMask < 0.01) discard;
1039
+
1040
+ // Altitude angle (Y is up)
1041
+ float h = normalize(vWorldNormal).y;
1042
+
1043
+ // 1. Base gradient from Horizon to Zenith (wider range)
1044
+ float t = smoothstep(-0.15, 0.7, h);
1045
+
1046
+ // Non-linear mix for realistic sky falloff
1047
+ vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
1048
+
1049
+ // 2. Teal tint at mid-altitudes (subtle colour variation)
1050
+ float midBand = exp(-6.0 * pow(h - 0.3, 2.0));
1051
+ skyColor += vec3(0.05, 0.12, 0.15) * midBand * uAtmGlow;
1052
+
1053
+ // 3. Primary horizon glow band (wider than before)
1054
+ float horizonBand = exp(-10.0 * abs(h - 0.02));
1055
+ skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
1056
+
1057
+ // 4. Warm secondary glow (light pollution / sodium scatter)
1058
+ float warmGlow = exp(-8.0 * abs(h));
1059
+ skyColor += vec3(0.4, 0.25, 0.15) * warmGlow * 0.3 * uAtmGlow;
1060
+
1061
+ gl_FragColor = vec4(skyColor, 1.0);
1062
+ }
1063
+ `,
1064
+ side: THREE5.BackSide,
572
1065
  depthWrite: false,
573
1066
  depthTest: true
574
1067
  });
575
- const atm = new THREE4.Mesh(geometry, material);
1068
+ const atm = new THREE5.Mesh(geometry, material);
1069
+ atmosphereMesh = atm;
576
1070
  groundGroup.add(atm);
577
1071
  }
578
- const backdropGroup = new THREE4.Group();
1072
+ const backdropGroup = new THREE5.Group();
579
1073
  scene.add(backdropGroup);
580
- function createBackdropStars() {
1074
+ function createBackdropStars(count = 31e3) {
581
1075
  backdropGroup.clear();
582
- const geometry = new THREE4.BufferGeometry();
1076
+ while (backdropGroup.children.length > 0) {
1077
+ const c = backdropGroup.children[0];
1078
+ backdropGroup.remove(c);
1079
+ if (c.geometry) c.geometry.dispose();
1080
+ if (c.material) c.material.dispose();
1081
+ }
1082
+ const geometry = new THREE5.BufferGeometry();
583
1083
  const positions = [];
584
1084
  const sizes = [];
585
1085
  const colors = [];
586
- const colorPalette = [
587
- new THREE4.Color(10203391),
588
- new THREE4.Color(11190271),
589
- new THREE4.Color(13293567),
590
- new THREE4.Color(16316415),
591
- new THREE4.Color(16774378),
592
- new THREE4.Color(16765601),
593
- new THREE4.Color(16764015)
594
- ];
595
1086
  const r = 2500;
596
- new THREE4.Vector3(0, 1, 0.5).normalize();
597
- for (let i = 0; i < 4e3; i++) {
598
- const isMilkyWay = Math.random() < 0.4;
599
- let x, y, z;
600
- if (isMilkyWay) {
601
- const theta = Math.random() * Math.PI * 2;
602
- const scatter = (Math.random() - 0.5) * 0.4;
603
- const v = new THREE4.Vector3(Math.cos(theta), scatter, Math.sin(theta));
604
- v.normalize();
605
- v.applyAxisAngle(new THREE4.Vector3(1, 0, 0), THREE4.MathUtils.degToRad(60));
606
- x = v.x * r;
607
- y = v.y * r;
608
- z = v.z * r;
609
- } else {
610
- const u = Math.random();
611
- const v = Math.random();
612
- const theta = 2 * Math.PI * u;
613
- const phi = Math.acos(2 * v - 1);
614
- x = r * Math.sin(phi) * Math.cos(theta);
615
- y = r * Math.sin(phi) * Math.sin(theta);
616
- z = r * Math.cos(phi);
617
- }
1087
+ for (let i = 0; i < count; i++) {
1088
+ const u = Math.random();
1089
+ const v = Math.random();
1090
+ const theta = 2 * Math.PI * u;
1091
+ const phi = Math.acos(2 * v - 1);
1092
+ const x = r * Math.sin(phi) * Math.cos(theta);
1093
+ const y = r * Math.cos(phi);
1094
+ const z = r * Math.sin(phi) * Math.sin(theta);
618
1095
  positions.push(x, y, z);
619
- const size = 0.5 + -Math.log(Math.random()) * 0.8 * 1.5;
1096
+ const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
620
1097
  sizes.push(size);
621
- const cIndex = Math.floor(Math.random() * colorPalette.length);
622
- const c = colorPalette[cIndex];
623
- colors.push(c.r, c.g, c.b);
1098
+ const temp = Math.random();
1099
+ let cr, cg, cb;
1100
+ if (temp < 0.15) {
1101
+ cr = 0.7 + temp * 2;
1102
+ cg = 0.8 + temp;
1103
+ cb = 1;
1104
+ } else if (temp < 0.6) {
1105
+ const t = (temp - 0.15) / 0.45;
1106
+ cr = 1;
1107
+ cg = 1 - t * 0.1;
1108
+ cb = 1 - t * 0.3;
1109
+ } else {
1110
+ const t = (temp - 0.6) / 0.4;
1111
+ cr = 1;
1112
+ cg = 0.85 - t * 0.35;
1113
+ cb = 0.7 - t * 0.35;
1114
+ }
1115
+ colors.push(cr, cg, cb);
624
1116
  }
625
- geometry.setAttribute("position", new THREE4.Float32BufferAttribute(positions, 3));
626
- geometry.setAttribute("size", new THREE4.Float32BufferAttribute(sizes, 1));
627
- geometry.setAttribute("color", new THREE4.Float32BufferAttribute(colors, 3));
1117
+ geometry.setAttribute("position", new THREE5.Float32BufferAttribute(positions, 3));
1118
+ geometry.setAttribute("size", new THREE5.Float32BufferAttribute(sizes, 1));
1119
+ geometry.setAttribute("color", new THREE5.Float32BufferAttribute(colors, 3));
628
1120
  const material = createSmartMaterial({
629
- uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
1121
+ uniforms: {
1122
+ pixelRatio: { value: renderer.getPixelRatio() },
1123
+ uScale: globalUniforms.uScale,
1124
+ uTime: globalUniforms.uTime
1125
+ },
630
1126
  vertexShaderBody: `
631
- attribute float size;
632
- attribute vec3 color;
633
- varying vec3 vColor;
634
- uniform float pixelRatio;
635
- void main() {
636
- vColor = color;
637
- vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
638
- gl_Position = smartProject(mvPosition);
639
- vScreenPos = gl_Position.xy / gl_Position.w;
640
- gl_PointSize = size * pixelRatio * (600.0 / -mvPosition.z);
1127
+ attribute float size;
1128
+ attribute vec3 color;
1129
+ varying vec3 vColor;
1130
+ uniform float pixelRatio;
1131
+
1132
+ uniform float uAtmExtinction;
1133
+ uniform float uAtmTwinkle;
1134
+ uniform float uTime;
1135
+
1136
+ void main() {
1137
+ vec3 nPos = normalize(position);
1138
+ float altitude = nPos.y;
1139
+
1140
+ // Extinction & Horizon Fade
1141
+ float horizonFade = smoothstep(-0.1, 0.1, altitude);
1142
+ float airmass = 1.0 / (max(0.05, altitude + 0.05));
1143
+ float extinction = exp(-uAtmExtinction * 0.15 * airmass);
1144
+
1145
+ // Scintillation (twinkling) \u2014 stronger near horizon
1146
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1147
+ float twinkle = sin(uTime * 3.0 + position.x * 0.05 + position.z * 0.03) * 0.5 + 0.5;
1148
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.4 * turbulence);
1149
+
1150
+ vColor = color * 3.0 * extinction * horizonFade * scintillation;
1151
+
1152
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1153
+ gl_Position = smartProject(mvPosition);
1154
+ vScreenPos = gl_Position.xy / gl_Position.w;
1155
+
1156
+ float zoomScale = pow(uScale, 0.5);
1157
+ float perceptualSize = pow(size, 0.55);
1158
+ gl_PointSize = clamp(perceptualSize * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade, 0.5, 20.0);
641
1159
  }
642
1160
  `,
643
1161
  fragmentShader: `
644
- varying vec3 vColor;
645
- void main() {
646
- vec2 coord = gl_PointCoord - vec2(0.5);
647
- float dist = length(coord) * 2.0;
648
- if (dist > 1.0) discard;
649
- float alphaMask = getMaskAlpha();
650
- if (alphaMask < 0.01) discard;
651
- // Use same Gaussian glow for backdrop
652
- float alpha = exp(-3.0 * dist * dist);
653
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1162
+ varying vec3 vColor;
1163
+ void main() {
1164
+ vec2 coord = gl_PointCoord - vec2(0.5);
1165
+ float d = length(coord) * 2.0;
1166
+ if (d > 1.0) discard;
1167
+ float alphaMask = getMaskAlpha();
1168
+ if (alphaMask < 0.01) discard;
1169
+
1170
+ // Stellarium-style: sharp core + soft glow
1171
+ float core = smoothstep(0.8, 0.4, d);
1172
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1173
+ float k = core + glow;
1174
+
1175
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.5);
1176
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
654
1177
  }
655
1178
  `,
656
1179
  transparent: true,
657
1180
  depthWrite: false,
658
- depthTest: true
1181
+ depthTest: true,
1182
+ blending: THREE5.AdditiveBlending
659
1183
  });
660
- const points = new THREE4.Points(geometry, material);
1184
+ const points = new THREE5.Points(geometry, material);
661
1185
  points.frustumCulled = false;
662
1186
  backdropGroup.add(points);
663
1187
  }
664
1188
  createGround();
665
1189
  createAtmosphere();
666
1190
  createBackdropStars();
667
- const raycaster = new THREE4.Raycaster();
1191
+ const raycaster = new THREE5.Raycaster();
668
1192
  raycaster.params.Points.threshold = 5;
669
- new THREE4.Vector2();
670
- const root = new THREE4.Group();
1193
+ new THREE5.Vector2();
1194
+ const root = new THREE5.Group();
671
1195
  scene.add(root);
672
1196
  const nodeById = /* @__PURE__ */ new Map();
673
1197
  const starIndexToId = [];
@@ -675,7 +1199,7 @@ function createEngine({
675
1199
  const hoverLabelMat = createSmartMaterial({
676
1200
  uniforms: {
677
1201
  uMap: { value: null },
678
- uSize: { value: new THREE4.Vector2(1, 1) },
1202
+ uSize: { value: new THREE5.Vector2(1, 1) },
679
1203
  uAlpha: { value: 0 },
680
1204
  uAngle: { value: 0 }
681
1205
  },
@@ -713,7 +1237,7 @@ function createEngine({
713
1237
  depthTest: false
714
1238
  // Always on top of stars
715
1239
  });
716
- const hoverLabelMesh = new THREE4.Mesh(new THREE4.PlaneGeometry(1, 1), hoverLabelMat);
1240
+ const hoverLabelMesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), hoverLabelMat);
717
1241
  hoverLabelMesh.visible = false;
718
1242
  hoverLabelMesh.renderOrder = 999;
719
1243
  hoverLabelMesh.frustumCulled = false;
@@ -722,6 +1246,9 @@ function createEngine({
722
1246
  let constellationLines = null;
723
1247
  let boundaryLines = null;
724
1248
  let starPoints = null;
1249
+ const linesFader = new Fader(0.4);
1250
+ const artFader = new Fader(0.5);
1251
+ let lastTickTime = 0;
725
1252
  function clearRoot() {
726
1253
  for (const child of [...root.children]) {
727
1254
  root.remove(child);
@@ -744,19 +1271,20 @@ function createEngine({
744
1271
  const ctx = canvas.getContext("2d");
745
1272
  if (!ctx) return null;
746
1273
  const fontSize = 96;
747
- ctx.font = `bold ${fontSize}px sans-serif`;
1274
+ const font = `400 ${fontSize}px "Inter", system-ui, sans-serif`;
1275
+ ctx.font = font;
748
1276
  const metrics = ctx.measureText(text);
749
1277
  const w = Math.ceil(metrics.width);
750
1278
  const h = Math.ceil(fontSize * 1.2);
751
1279
  canvas.width = w;
752
1280
  canvas.height = h;
753
- ctx.font = `bold ${fontSize}px sans-serif`;
1281
+ ctx.font = font;
754
1282
  ctx.fillStyle = color;
755
1283
  ctx.textAlign = "center";
756
1284
  ctx.textBaseline = "middle";
757
1285
  ctx.fillText(text, w / 2, h / 2);
758
- const tex = new THREE4.CanvasTexture(canvas);
759
- tex.minFilter = THREE4.LinearFilter;
1286
+ const tex = new THREE5.CanvasTexture(canvas);
1287
+ tex.minFilter = THREE5.LinearFilter;
760
1288
  return { tex, aspect: w / h };
761
1289
  }
762
1290
  function getPosition(n) {
@@ -770,27 +1298,30 @@ function createEngine({
770
1298
  const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
771
1299
  const phi = Math.atan2(y, x);
772
1300
  const theta = r_norm * (Math.PI / 2);
773
- return new THREE4.Vector3(
1301
+ return new THREE5.Vector3(
774
1302
  Math.sin(theta) * Math.cos(phi),
775
1303
  Math.cos(theta),
776
1304
  Math.sin(theta) * Math.sin(phi)
777
1305
  ).multiplyScalar(radius);
778
1306
  }
779
- return new THREE4.Vector3(arr.position[0], arr.position[1], arr.position[2]);
1307
+ return new THREE5.Vector3(arr.position[0], arr.position[1], arr.position[2]);
780
1308
  }
781
1309
  }
782
- return new THREE4.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
1310
+ return new THREE5.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
783
1311
  }
784
1312
  function getBoundaryPoint(angle, t, radius) {
785
1313
  const y = 0.05 + t * (1 - 0.05);
786
1314
  const rY = Math.sqrt(1 - y * y);
787
1315
  const x = Math.cos(angle) * rY;
788
1316
  const z = Math.sin(angle) * rY;
789
- return new THREE4.Vector3(x, y, z).multiplyScalar(radius);
1317
+ return new THREE5.Vector3(x, y, z).multiplyScalar(radius);
790
1318
  }
791
1319
  function buildFromModel(model, cfg) {
792
1320
  clearRoot();
793
- scene.background = cfg.background && cfg.background !== "transparent" ? new THREE4.Color(cfg.background) : new THREE4.Color(0);
1321
+ bookIdToIndex.clear();
1322
+ testamentToIndex.clear();
1323
+ divisionToIndex.clear();
1324
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5.Color(cfg.background) : new THREE5.Color(0);
794
1325
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
795
1326
  const laidOut = computeLayoutPositions(model, layoutCfg);
796
1327
  const divisionPositions = /* @__PURE__ */ new Map();
@@ -804,7 +1335,7 @@ function createEngine({
804
1335
  }
805
1336
  }
806
1337
  for (const [divId, books] of divMap.entries()) {
807
- const centroid = new THREE4.Vector3();
1338
+ const centroid = new THREE5.Vector3();
808
1339
  let count = 0;
809
1340
  for (const b of books) {
810
1341
  const p = getPosition(b);
@@ -820,21 +1351,26 @@ function createEngine({
820
1351
  const starPositions = [];
821
1352
  const starSizes = [];
822
1353
  const starColors = [];
1354
+ const starPhases = [];
1355
+ const starBookIndices = [];
1356
+ const starChapterIndices = [];
1357
+ const starTestamentIndices = [];
1358
+ const starDivisionIndices = [];
823
1359
  const SPECTRAL_COLORS = [
824
- new THREE4.Color(10203391),
825
- // O - Blue
826
- new THREE4.Color(11190271),
827
- // B - Blue-white
828
- new THREE4.Color(13293567),
829
- // A - White-blue
830
- new THREE4.Color(16316415),
1360
+ new THREE5.Color(14544639),
1361
+ // O - Blueish White
1362
+ new THREE5.Color(15660287),
1363
+ // B - White
1364
+ new THREE5.Color(16317695),
1365
+ // A - White
1366
+ new THREE5.Color(16777208),
831
1367
  // F - White
832
- new THREE4.Color(16774378),
833
- // G - Yellow-white
834
- new THREE4.Color(16765601),
835
- // K - Yellow-orange
836
- new THREE4.Color(16764015)
837
- // M - Orange-red
1368
+ new THREE5.Color(16775406),
1369
+ // G - Yellowish White
1370
+ new THREE5.Color(16773085),
1371
+ // K - Pale Orange
1372
+ new THREE5.Color(16771788)
1373
+ // M - Light Orange
838
1374
  ];
839
1375
  let minWeight = Infinity;
840
1376
  let maxWeight = -Infinity;
@@ -859,21 +1395,61 @@ function createEngine({
859
1395
  let baseSize = 3.5;
860
1396
  if (typeof n.weight === "number") {
861
1397
  const t = (n.weight - minWeight) / (maxWeight - minWeight);
862
- baseSize = 3 + t * 4;
1398
+ baseSize = 0.1 + Math.pow(t, 0.5) * 11.9;
863
1399
  }
864
1400
  starSizes.push(baseSize);
865
1401
  const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
866
1402
  const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
867
1403
  starColors.push(c.r, c.g, c.b);
1404
+ starPhases.push(Math.random() * Math.PI * 2);
1405
+ let bIdx = -1;
1406
+ if (n.parent) {
1407
+ if (!bookIdToIndex.has(n.parent)) {
1408
+ bookIdToIndex.set(n.parent, bookIdToIndex.size + 1);
1409
+ }
1410
+ bIdx = bookIdToIndex.get(n.parent);
1411
+ }
1412
+ starBookIndices.push(bIdx);
1413
+ let cIdx = 0;
1414
+ if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1415
+ starChapterIndices.push(cIdx);
1416
+ let tIdx = -1;
1417
+ if (n.meta?.testament) {
1418
+ const tName = n.meta.testament;
1419
+ if (!testamentToIndex.has(tName)) {
1420
+ testamentToIndex.set(tName, testamentToIndex.size + 1);
1421
+ }
1422
+ tIdx = testamentToIndex.get(tName);
1423
+ }
1424
+ starTestamentIndices.push(tIdx);
1425
+ let dIdx = -1;
1426
+ if (n.meta?.division) {
1427
+ const dName = n.meta.division;
1428
+ if (!divisionToIndex.has(dName)) {
1429
+ divisionToIndex.set(dName, divisionToIndex.size + 1);
1430
+ }
1431
+ dIdx = divisionToIndex.get(dName);
1432
+ }
1433
+ starDivisionIndices.push(dIdx);
868
1434
  }
869
1435
  if (n.level === 1 || n.level === 2 || n.level === 3) {
870
- const color = n.level === 1 ? "#38bdf8" : "#ffffff";
871
- const texRes = createTextTexture(n.label, color);
1436
+ let color = "#ffffff";
1437
+ if (n.level === 1) color = "#38bdf8";
1438
+ else if (n.level === 2) {
1439
+ const bookKey = n.meta?.bookKey;
1440
+ color = bookKey && cfg.labelColors?.[bookKey] || "#cbd5e1";
1441
+ } else if (n.level === 3) color = "#94a3b8";
1442
+ let labelText = n.label;
1443
+ if (n.level === 3 && n.meta?.chapter) {
1444
+ labelText = String(n.meta.chapter);
1445
+ }
1446
+ const texRes = createTextTexture(labelText, color);
872
1447
  if (texRes) {
873
1448
  let baseScale = 0.05;
874
1449
  if (n.level === 1) baseScale = 0.08;
875
- else if (n.level === 3) baseScale = 0.04;
876
- const size = new THREE4.Vector2(baseScale * texRes.aspect, baseScale);
1450
+ else if (n.level === 2) baseScale = 0.04;
1451
+ else if (n.level === 3) baseScale = 0.03;
1452
+ const size = new THREE5.Vector2(baseScale * texRes.aspect, baseScale);
877
1453
  const mat = createSmartMaterial({
878
1454
  uniforms: {
879
1455
  uMap: { value: texRes.tex },
@@ -914,7 +1490,7 @@ function createEngine({
914
1490
  depthWrite: false,
915
1491
  depthTest: true
916
1492
  });
917
- const mesh = new THREE4.Mesh(new THREE4.PlaneGeometry(1, 1), mat);
1493
+ const mesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), mat);
918
1494
  let p = getPosition(n);
919
1495
  if (n.level === 1) {
920
1496
  if (divisionPositions.has(n.id)) {
@@ -924,7 +1500,8 @@ function createEngine({
924
1500
  const angle = Math.atan2(p.z, p.x);
925
1501
  p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
926
1502
  } else if (n.level === 3) {
927
- p.multiplyScalar(1.002);
1503
+ p.y += 30;
1504
+ p.multiplyScalar(1.001);
928
1505
  }
929
1506
  mesh.position.set(p.x, p.y, p.z);
930
1507
  mesh.scale.set(size.x, size.y, 1);
@@ -935,47 +1512,147 @@ function createEngine({
935
1512
  }
936
1513
  }
937
1514
  }
938
- const starGeo = new THREE4.BufferGeometry();
939
- starGeo.setAttribute("position", new THREE4.Float32BufferAttribute(starPositions, 3));
940
- starGeo.setAttribute("size", new THREE4.Float32BufferAttribute(starSizes, 1));
941
- starGeo.setAttribute("color", new THREE4.Float32BufferAttribute(starColors, 3));
1515
+ const starGeo = new THREE5.BufferGeometry();
1516
+ starGeo.setAttribute("position", new THREE5.Float32BufferAttribute(starPositions, 3));
1517
+ starGeo.setAttribute("size", new THREE5.Float32BufferAttribute(starSizes, 1));
1518
+ starGeo.setAttribute("color", new THREE5.Float32BufferAttribute(starColors, 3));
1519
+ starGeo.setAttribute("phase", new THREE5.Float32BufferAttribute(starPhases, 1));
1520
+ starGeo.setAttribute("bookIndex", new THREE5.Float32BufferAttribute(starBookIndices, 1));
1521
+ starGeo.setAttribute("chapterIndex", new THREE5.Float32BufferAttribute(starChapterIndices, 1));
1522
+ starGeo.setAttribute("testamentIndex", new THREE5.Float32BufferAttribute(starTestamentIndices, 1));
1523
+ starGeo.setAttribute("divisionIndex", new THREE5.Float32BufferAttribute(starDivisionIndices, 1));
942
1524
  const starMat = createSmartMaterial({
943
- uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
1525
+ uniforms: {
1526
+ pixelRatio: { value: renderer.getPixelRatio() },
1527
+ uScale: globalUniforms.uScale,
1528
+ uTime: globalUniforms.uTime,
1529
+ uActiveBookIndex: { value: -1 },
1530
+ uOrderRevealStrength: { value: 0 },
1531
+ uGlobalDimFactor: { value: ORDER_REVEAL_CONFIG.globalDim },
1532
+ uPulseParams: { value: new THREE5.Vector3(
1533
+ ORDER_REVEAL_CONFIG.pulseDuration,
1534
+ ORDER_REVEAL_CONFIG.delayPerChapter,
1535
+ ORDER_REVEAL_CONFIG.pulseAmplitude
1536
+ ) },
1537
+ uFilterTestamentIndex: { value: -1 },
1538
+ uFilterDivisionIndex: { value: -1 },
1539
+ uFilterBookIndex: { value: -1 },
1540
+ uFilterStrength: { value: 0 },
1541
+ uFilterDimFactor: { value: 0.08 }
1542
+ },
944
1543
  vertexShaderBody: `
945
1544
  attribute float size;
946
1545
  attribute vec3 color;
947
- varying vec3 vColor;
948
- uniform float pixelRatio;
1546
+ attribute float phase;
1547
+ attribute float bookIndex;
1548
+ attribute float chapterIndex;
1549
+ attribute float testamentIndex;
1550
+ attribute float divisionIndex;
1551
+
1552
+ varying vec3 vColor;
1553
+ uniform float pixelRatio;
1554
+
1555
+ uniform float uTime;
1556
+ uniform float uAtmExtinction;
1557
+ uniform float uAtmTwinkle;
1558
+
1559
+ uniform float uActiveBookIndex;
1560
+ uniform float uOrderRevealStrength;
1561
+ uniform float uGlobalDimFactor;
1562
+ uniform vec3 uPulseParams;
1563
+
1564
+ uniform float uFilterTestamentIndex;
1565
+ uniform float uFilterDivisionIndex;
1566
+ uniform float uFilterBookIndex;
1567
+ uniform float uFilterStrength;
1568
+ uniform float uFilterDimFactor;
1569
+
949
1570
  void main() {
950
- vColor = color;
1571
+ vec3 nPos = normalize(position);
1572
+
1573
+ // 1. Altitude (Y is UP)
1574
+ float altitude = nPos.y;
1575
+
1576
+ // 2. Atmospheric Extinction (Airmass approximation)
1577
+ float airmass = 1.0 / (max(0.02, altitude + 0.05));
1578
+ float extinction = exp(-uAtmExtinction * 0.1 * airmass);
1579
+
1580
+ // Fade out stars below horizon
1581
+ float horizonFade = smoothstep(-0.1, 0.05, altitude);
1582
+
1583
+ // 3. Scintillation
1584
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1585
+ float twinkle = sin(uTime * 3.0 + phase + position.x * 0.01) * 0.5 + 0.5;
1586
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.5 * turbulence);
1587
+
1588
+ // --- Order Reveal Logic ---
1589
+ float isTarget = 1.0 - min(1.0, abs(bookIndex - uActiveBookIndex));
1590
+
1591
+ // Dimming
1592
+ float dimFactor = mix(1.0, uGlobalDimFactor, uOrderRevealStrength * (1.0 - isTarget));
1593
+
1594
+ // Pulse
1595
+ float delay = chapterIndex * uPulseParams.y;
1596
+ float cycleDuration = uPulseParams.x * 2.5;
1597
+ float t = mod(uTime - delay, cycleDuration);
1598
+
1599
+ float pulse = smoothstep(0.0, 0.2, t) * (1.0 - smoothstep(0.4, uPulseParams.x, t));
1600
+ pulse = max(0.0, pulse);
1601
+
1602
+ float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1603
+
1604
+ // --- Hierarchy Filter ---
1605
+ float filtered = 0.0;
1606
+ if (uFilterTestamentIndex >= 0.0) {
1607
+ filtered = 1.0 - step(0.5, 1.0 - abs(testamentIndex - uFilterTestamentIndex));
1608
+ }
1609
+ if (uFilterDivisionIndex >= 0.0 && filtered < 0.5) {
1610
+ filtered = 1.0 - step(0.5, 1.0 - abs(divisionIndex - uFilterDivisionIndex));
1611
+ }
1612
+ if (uFilterBookIndex >= 0.0 && filtered < 0.5) {
1613
+ filtered = 1.0 - step(0.5, 1.0 - abs(bookIndex - uFilterBookIndex));
1614
+ }
1615
+ float filterDim = mix(1.0, uFilterDimFactor, uFilterStrength * filtered);
1616
+
1617
+ vec3 baseColor = color * extinction * horizonFade * scintillation;
1618
+ vColor = baseColor * dimFactor * filterDim;
1619
+ vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1620
+
951
1621
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
952
1622
  gl_Position = smartProject(mvPosition);
953
1623
  vScreenPos = gl_Position.xy / gl_Position.w;
954
- gl_PointSize = size * pixelRatio * (2000.0 / -mvPosition.z);
1624
+
1625
+ float sizeBoost = 1.0 + activePulse * 0.8;
1626
+ float perceptualSize = pow(size, 0.55);
1627
+ gl_PointSize = clamp((perceptualSize * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade, 1.0, 40.0);
955
1628
  }
956
1629
  `,
957
1630
  fragmentShader: `
958
1631
  varying vec3 vColor;
959
1632
  void main() {
960
1633
  vec2 coord = gl_PointCoord - vec2(0.5);
961
- // Use larger drawing area for glow
962
- float dist = length(coord) * 2.0;
963
- if (dist > 1.0) discard;
1634
+ float d = length(coord) * 2.0;
1635
+ if (d > 1.0) discard;
964
1636
 
965
1637
  float alphaMask = getMaskAlpha();
966
1638
  if (alphaMask < 0.01) discard;
967
1639
 
968
- // Gaussian Glow: Sharp core, soft halo
969
- float alpha = exp(-3.0 * dist * dist);
970
-
971
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1640
+ // Stellarium-style dual-layer: sharp core + soft glow
1641
+ float core = smoothstep(0.8, 0.4, d);
1642
+ float glow = smoothstep(1.0, 0.0, d) * 0.08;
1643
+ float k = core + glow;
1644
+
1645
+ // White-hot core blending into coloured halo
1646
+ vec3 finalColor = mix(vColor, vec3(1.0), core * 0.7);
1647
+ gl_FragColor = vec4(finalColor * k * alphaMask, 1.0);
972
1648
  }
973
1649
  `,
974
1650
  transparent: true,
975
1651
  depthWrite: false,
976
- depthTest: true
1652
+ depthTest: true,
1653
+ blending: THREE5.AdditiveBlending
977
1654
  });
978
- starPoints = new THREE4.Points(starGeo, starMat);
1655
+ starPoints = new THREE5.Points(starGeo, starMat);
979
1656
  starPoints.frustumCulled = false;
980
1657
  root.add(starPoints);
981
1658
  const linePoints = [];
@@ -1001,31 +1678,191 @@ function createEngine({
1001
1678
  }
1002
1679
  }
1003
1680
  if (linePoints.length > 0) {
1004
- const lineGeo = new THREE4.BufferGeometry();
1005
- lineGeo.setAttribute("position", new THREE4.Float32BufferAttribute(linePoints, 3));
1681
+ const quadPositions = [];
1682
+ const quadUvs = [];
1683
+ const quadIndices = [];
1684
+ const lineWidth = 8;
1685
+ for (let i = 0; i < linePoints.length; i += 6) {
1686
+ const ax = linePoints[i], ay = linePoints[i + 1], az = linePoints[i + 2];
1687
+ const bx = linePoints[i + 3], by = linePoints[i + 4], bz = linePoints[i + 5];
1688
+ const dx = bx - ax, dy = by - ay, dz = bz - az;
1689
+ const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
1690
+ if (len < 1e-3) continue;
1691
+ let px = dy * 0 - dz * 1, py = dz * 0 - dx * 0, pz = dx * 1 - dy * 0;
1692
+ const pLen = Math.sqrt(px * px + py * py + pz * pz);
1693
+ if (pLen < 1e-3) {
1694
+ px = 1;
1695
+ py = 0;
1696
+ pz = 0;
1697
+ } else {
1698
+ px /= pLen;
1699
+ py /= pLen;
1700
+ pz /= pLen;
1701
+ }
1702
+ const hw = lineWidth;
1703
+ const baseIdx = quadPositions.length / 3;
1704
+ quadPositions.push(ax - px * hw, ay - py * hw, az - pz * hw);
1705
+ quadUvs.push(0, -1);
1706
+ quadPositions.push(ax + px * hw, ay + py * hw, az + pz * hw);
1707
+ quadUvs.push(0, 1);
1708
+ quadPositions.push(bx - px * hw, by - py * hw, bz - pz * hw);
1709
+ quadUvs.push(1, -1);
1710
+ quadPositions.push(bx + px * hw, by + py * hw, bz + pz * hw);
1711
+ quadUvs.push(1, 1);
1712
+ quadIndices.push(baseIdx, baseIdx + 1, baseIdx + 2, baseIdx + 1, baseIdx + 3, baseIdx + 2);
1713
+ }
1714
+ const lineGeo = new THREE5.BufferGeometry();
1715
+ lineGeo.setAttribute("position", new THREE5.Float32BufferAttribute(quadPositions, 3));
1716
+ lineGeo.setAttribute("lineUv", new THREE5.Float32BufferAttribute(quadUvs, 2));
1717
+ lineGeo.setIndex(quadIndices);
1006
1718
  const lineMat = createSmartMaterial({
1007
- uniforms: { color: { value: new THREE4.Color(11193599) } },
1008
- 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; }`,
1009
- fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.4 * alphaMask); }`,
1719
+ uniforms: {
1720
+ color: { value: new THREE5.Color(11193599) },
1721
+ uLineWidth: { value: 1.5 },
1722
+ uGlowIntensity: { value: 0.3 }
1723
+ },
1724
+ vertexShaderBody: `
1725
+ attribute vec2 lineUv;
1726
+ varying vec2 vLineUv;
1727
+ void main() {
1728
+ vLineUv = lineUv;
1729
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
1730
+ gl_Position = smartProject(mvPosition);
1731
+ vScreenPos = gl_Position.xy / gl_Position.w;
1732
+ }
1733
+ `,
1734
+ fragmentShader: `
1735
+ uniform vec3 color;
1736
+ uniform float uLineWidth;
1737
+ uniform float uGlowIntensity;
1738
+ varying vec2 vLineUv;
1739
+ void main() {
1740
+ float alphaMask = getMaskAlpha();
1741
+ if (alphaMask < 0.01) discard;
1742
+
1743
+ float dist = abs(vLineUv.y);
1744
+
1745
+ // Anti-aliased core line
1746
+ float hw = uLineWidth * 0.05;
1747
+ float base = smoothstep(hw + 0.08, hw - 0.08, dist);
1748
+
1749
+ // Soft glow extending outward
1750
+ float glow = (1.0 - dist) * uGlowIntensity;
1751
+
1752
+ float alpha = max(glow, base);
1753
+ if (alpha < 0.005) discard;
1754
+
1755
+ gl_FragColor = vec4(color, alpha * alphaMask);
1756
+ }
1757
+ `,
1010
1758
  transparent: true,
1011
1759
  depthWrite: false,
1012
- blending: THREE4.AdditiveBlending
1760
+ blending: THREE5.AdditiveBlending,
1761
+ side: THREE5.DoubleSide
1013
1762
  });
1014
- constellationLines = new THREE4.LineSegments(lineGeo, lineMat);
1763
+ constellationLines = new THREE5.Mesh(lineGeo, lineMat);
1015
1764
  constellationLines.frustumCulled = false;
1016
1765
  root.add(constellationLines);
1017
1766
  }
1767
+ if (cfg.groups) {
1768
+ for (const [bookId, chapters] of bookMap.entries()) {
1769
+ const bookNode = nodeById.get(bookId);
1770
+ if (!bookNode) continue;
1771
+ const bookName = bookNode.meta?.book || bookNode.label;
1772
+ const groupList = cfg.groups[bookName.toLowerCase()];
1773
+ if (groupList) {
1774
+ groupList.forEach((g, idx) => {
1775
+ const groupId = `G:${bookId}:${idx}`;
1776
+ let p = new THREE5.Vector3();
1777
+ if (cfg.arrangement && cfg.arrangement[groupId]) {
1778
+ const arr = cfg.arrangement[groupId];
1779
+ p.set(arr.position[0], arr.position[1], arr.position[2]);
1780
+ } else {
1781
+ const relevantChapters = chapters.filter((c) => {
1782
+ const ch = c.meta?.chapter;
1783
+ return ch >= g.start && ch <= g.end;
1784
+ });
1785
+ if (relevantChapters.length === 0) return;
1786
+ for (const c of relevantChapters) {
1787
+ p.add(getPosition(c));
1788
+ }
1789
+ p.divideScalar(relevantChapters.length);
1790
+ }
1791
+ const labelText = `${g.name} (${g.start}-${g.end})`;
1792
+ const texRes = createTextTexture(labelText, "#4fa4fa80");
1793
+ if (texRes) {
1794
+ const baseScale = 0.036;
1795
+ const size = new THREE5.Vector2(baseScale * texRes.aspect, baseScale);
1796
+ const mat = createSmartMaterial({
1797
+ uniforms: {
1798
+ uMap: { value: texRes.tex },
1799
+ uSize: { value: size },
1800
+ uAlpha: { value: 0 },
1801
+ uAngle: { value: 0 }
1802
+ },
1803
+ vertexShaderBody: `
1804
+ uniform vec2 uSize;
1805
+ uniform float uAngle;
1806
+ varying vec2 vUv;
1807
+ void main() {
1808
+ vUv = uv;
1809
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1810
+ vec4 projected = smartProject(mvPos);
1811
+
1812
+ float c = cos(uAngle);
1813
+ float s = sin(uAngle);
1814
+ mat2 rot = mat2(c, -s, s, c);
1815
+ vec2 offset = rot * (position.xy * uSize);
1816
+
1817
+ projected.xy += offset / vec2(uAspect, 1.0);
1818
+ gl_Position = projected;
1819
+ }
1820
+ `,
1821
+ fragmentShader: `
1822
+ uniform sampler2D uMap;
1823
+ uniform float uAlpha;
1824
+ varying vec2 vUv;
1825
+ void main() {
1826
+ float mask = getMaskAlpha();
1827
+ if (mask < 0.01) discard;
1828
+ vec4 tex = texture2D(uMap, vUv);
1829
+ gl_FragColor = vec4(tex.rgb, tex.a * uAlpha * mask);
1830
+ }
1831
+ `,
1832
+ transparent: true,
1833
+ depthWrite: false,
1834
+ depthTest: true
1835
+ });
1836
+ const mesh = new THREE5.Mesh(new THREE5.PlaneGeometry(1, 1), mat);
1837
+ mesh.position.copy(p);
1838
+ mesh.scale.set(size.x, size.y, 1);
1839
+ mesh.frustumCulled = false;
1840
+ mesh.userData = { id: groupId };
1841
+ root.add(mesh);
1842
+ const node = {
1843
+ id: groupId,
1844
+ label: labelText,
1845
+ level: 2.5,
1846
+ // Special Level
1847
+ parent: bookId
1848
+ };
1849
+ dynamicLabels.push({ obj: mesh, node, initialScale: size.clone() });
1850
+ }
1851
+ });
1852
+ }
1853
+ }
1854
+ }
1018
1855
  const boundaries = laidOut.meta?.divisionBoundaries ?? [];
1019
1856
  if (boundaries.length > 0) {
1020
1857
  const boundaryMat = createSmartMaterial({
1021
- uniforms: { color: { value: new THREE4.Color(5601177) } },
1858
+ uniforms: { color: { value: new THREE5.Color(5601177) } },
1022
1859
  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; }`,
1023
1860
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
1024
1861
  transparent: true,
1025
1862
  depthWrite: false,
1026
- blending: THREE4.AdditiveBlending
1863
+ blending: THREE5.AdditiveBlending
1027
1864
  });
1028
- const boundaryGeo = new THREE4.BufferGeometry();
1865
+ const boundaryGeo = new THREE5.BufferGeometry();
1029
1866
  const bPoints = [];
1030
1867
  boundaries.forEach((angle) => {
1031
1868
  const steps = 32;
@@ -1038,8 +1875,8 @@ function createEngine({
1038
1875
  bPoints.push(p2.x, p2.y, p2.z);
1039
1876
  }
1040
1877
  });
1041
- boundaryGeo.setAttribute("position", new THREE4.Float32BufferAttribute(bPoints, 3));
1042
- boundaryLines = new THREE4.LineSegments(boundaryGeo, boundaryMat);
1878
+ boundaryGeo.setAttribute("position", new THREE5.Float32BufferAttribute(bPoints, 3));
1879
+ boundaryLines = new THREE5.LineSegments(boundaryGeo, boundaryMat);
1043
1880
  boundaryLines.frustumCulled = false;
1044
1881
  root.add(boundaryLines);
1045
1882
  }
@@ -1058,7 +1895,7 @@ function createEngine({
1058
1895
  const r_norm = Math.sqrt(x * x + y * y);
1059
1896
  const phi = Math.atan2(y, x);
1060
1897
  const theta = r_norm * (Math.PI / 2);
1061
- return new THREE4.Vector3(
1898
+ return new THREE5.Vector3(
1062
1899
  Math.sin(theta) * Math.cos(phi),
1063
1900
  Math.cos(theta),
1064
1901
  Math.sin(theta) * Math.sin(phi)
@@ -1071,18 +1908,18 @@ function createEngine({
1071
1908
  }
1072
1909
  }
1073
1910
  if (polyPoints.length > 0) {
1074
- const polyGeo = new THREE4.BufferGeometry();
1075
- polyGeo.setAttribute("position", new THREE4.Float32BufferAttribute(polyPoints, 3));
1911
+ const polyGeo = new THREE5.BufferGeometry();
1912
+ polyGeo.setAttribute("position", new THREE5.Float32BufferAttribute(polyPoints, 3));
1076
1913
  const polyMat = createSmartMaterial({
1077
- uniforms: { color: { value: new THREE4.Color(3718648) } },
1914
+ uniforms: { color: { value: new THREE5.Color(3718648) } },
1078
1915
  // Cyan-ish
1079
1916
  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; }`,
1080
1917
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
1081
1918
  transparent: true,
1082
1919
  depthWrite: false,
1083
- blending: THREE4.AdditiveBlending
1920
+ blending: THREE5.AdditiveBlending
1084
1921
  });
1085
- const polyLines = new THREE4.LineSegments(polyGeo, polyMat);
1922
+ const polyLines = new THREE5.LineSegments(polyGeo, polyMat);
1086
1923
  polyLines.frustumCulled = false;
1087
1924
  root.add(polyLines);
1088
1925
  }
@@ -1094,8 +1931,16 @@ function createEngine({
1094
1931
  let lastModel = void 0;
1095
1932
  let lastAppliedLon = void 0;
1096
1933
  let lastAppliedLat = void 0;
1934
+ let lastBackdropCount = void 0;
1935
+ function setProjection(id) {
1936
+ const factory = PROJECTIONS[id];
1937
+ if (!factory) return;
1938
+ currentProjection = factory();
1939
+ updateUniforms();
1940
+ }
1097
1941
  function setConfig(cfg) {
1098
1942
  currentConfig = cfg;
1943
+ if (cfg.projection) setProjection(cfg.projection);
1099
1944
  if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1100
1945
  state.lon = cfg.camera.lon;
1101
1946
  state.targetLon = cfg.camera.lon;
@@ -1106,6 +1951,11 @@ function createEngine({
1106
1951
  state.targetLat = cfg.camera.lat;
1107
1952
  lastAppliedLat = cfg.camera.lat;
1108
1953
  }
1954
+ const desiredBackdropCount = typeof cfg.backdropStarsCount === "number" ? cfg.backdropStarsCount : 4e3;
1955
+ if (lastBackdropCount !== desiredBackdropCount) {
1956
+ createBackdropStars(desiredBackdropCount);
1957
+ lastBackdropCount = desiredBackdropCount;
1958
+ }
1109
1959
  let shouldRebuild = false;
1110
1960
  let model = cfg.model;
1111
1961
  if (!model && cfg.data && cfg.adapter) {
@@ -1129,6 +1979,29 @@ function createEngine({
1129
1979
  } else if (cfg.arrangement && starPoints) {
1130
1980
  if (lastModel) buildFromModel(lastModel, cfg);
1131
1981
  }
1982
+ if (cfg.constellations) {
1983
+ constellationLayer.load(cfg.constellations, (id) => {
1984
+ if (cfg.arrangement && cfg.arrangement[id]) {
1985
+ const arr = cfg.arrangement[id];
1986
+ if (arr.position[2] === 0) {
1987
+ const x = arr.position[0];
1988
+ const y = arr.position[1];
1989
+ const radius = cfg.layout?.radius ?? 2e3;
1990
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
1991
+ const phi = Math.atan2(y, x);
1992
+ const theta = r_norm * (Math.PI / 2);
1993
+ return new THREE5.Vector3(
1994
+ Math.sin(theta) * Math.cos(phi),
1995
+ Math.cos(theta),
1996
+ Math.sin(theta) * Math.sin(phi)
1997
+ ).multiplyScalar(radius);
1998
+ }
1999
+ return new THREE5.Vector3(arr.position[0], arr.position[1], arr.position[2]);
2000
+ }
2001
+ const n = nodeById.get(id);
2002
+ return n ? getPosition(n) : null;
2003
+ });
2004
+ }
1132
2005
  }
1133
2006
  function setHandlers(next) {
1134
2007
  handlers = next;
@@ -1148,27 +2021,42 @@ function createEngine({
1148
2021
  }
1149
2022
  }
1150
2023
  for (const item of dynamicLabels) {
2024
+ if (item.node.level === 3) continue;
1151
2025
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
1152
2026
  }
2027
+ for (const item of constellationLayer.getItems()) {
2028
+ arr[item.config.id] = { position: [item.mesh.position.x, item.mesh.position.y, item.mesh.position.z] };
2029
+ }
1153
2030
  Object.assign(arr, state.tempArrangement);
1154
2031
  return arr;
1155
2032
  }
2033
+ function isNodeFiltered(node) {
2034
+ if (!currentFilter) return false;
2035
+ const meta = node.meta;
2036
+ if (!meta) return false;
2037
+ if (currentFilter.testament && meta.testament !== currentFilter.testament) return true;
2038
+ if (currentFilter.division && meta.division !== currentFilter.division) return true;
2039
+ if (currentFilter.bookKey && meta.bookKey !== currentFilter.bookKey) return true;
2040
+ return false;
2041
+ }
1156
2042
  function pick(ev) {
1157
2043
  const rect = renderer.domElement.getBoundingClientRect();
1158
2044
  const mX = ev.clientX - rect.left;
1159
2045
  const mY = ev.clientY - rect.top;
1160
2046
  mouseNDC.x = mX / rect.width * 2 - 1;
1161
2047
  mouseNDC.y = -(mY / rect.height) * 2 + 1;
1162
- let closestLabel = null;
1163
- let minLabelDist = 40;
1164
2048
  const uScale = globalUniforms.uScale.value;
1165
2049
  const uAspect = camera.aspect;
1166
2050
  const w = rect.width;
1167
2051
  const h = rect.height;
2052
+ let closestLabel = null;
2053
+ let minLabelDist = 40;
1168
2054
  for (const item of dynamicLabels) {
1169
2055
  if (!item.obj.visible) continue;
2056
+ if (isNodeFiltered(item.node)) continue;
1170
2057
  const pWorld = item.obj.position;
1171
2058
  const pProj = smartProjectJS(pWorld);
2059
+ if (currentProjection.isClipped(pProj.z)) continue;
1172
2060
  const xNDC = pProj.x * uScale / uAspect;
1173
2061
  const yNDC = pProj.y * uScale;
1174
2062
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1176,28 +2064,79 @@ function createEngine({
1176
2064
  const dx = mX - sX;
1177
2065
  const dy = mY - sY;
1178
2066
  const d = Math.sqrt(dx * dx + dy * dy);
1179
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1180
- if (!isBehind && d < minLabelDist) {
2067
+ if (d < minLabelDist) {
1181
2068
  minLabelDist = d;
1182
2069
  closestLabel = item;
1183
2070
  }
1184
2071
  }
1185
- if (closestLabel) return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
1186
- const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
1187
- raycaster.ray.origin.set(0, 0, 0);
1188
- raycaster.ray.direction.copy(worldDir);
1189
- raycaster.params.Points.threshold = 5 * (state.fov / 60);
1190
- const hits = raycaster.intersectObject(starPoints, false);
1191
- const pointHit = hits[0];
1192
- if (pointHit && pointHit.index !== void 0) {
1193
- const id = starIndexToId[pointHit.index];
1194
- if (id) {
1195
- const node = nodeById.get(id);
1196
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2072
+ if (closestLabel) {
2073
+ return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
2074
+ }
2075
+ let closestConst = null;
2076
+ let minConstDist = Infinity;
2077
+ for (const item of constellationLayer.getItems()) {
2078
+ if (!item.mesh.visible) continue;
2079
+ const pWorld = item.mesh.position;
2080
+ const pProj = smartProjectJS(pWorld);
2081
+ if (currentProjection.isClipped(pProj.z)) continue;
2082
+ const uniforms = item.material.uniforms;
2083
+ if (!uniforms || !uniforms.uSize) continue;
2084
+ const uSize = uniforms.uSize.value;
2085
+ const uImgAspect = uniforms.uImgAspect.value;
2086
+ const uImgRotation = uniforms.uImgRotation.value;
2087
+ const dist = pWorld.length();
2088
+ if (dist < 1e-3) continue;
2089
+ const scale = uSize / dist * uScale;
2090
+ const halfH_px = scale / 2 * (h / 2);
2091
+ const halfW_px = halfH_px * uImgAspect;
2092
+ const xNDC = pProj.x * uScale / uAspect;
2093
+ const yNDC = pProj.y * uScale;
2094
+ const sX = (xNDC * 0.5 + 0.5) * w;
2095
+ const sY = (-yNDC * 0.5 + 0.5) * h;
2096
+ const dx = mX - sX;
2097
+ const dy = mY - sY;
2098
+ const dy_cart = -dy;
2099
+ const cr = Math.cos(-uImgRotation);
2100
+ const sr = Math.sin(-uImgRotation);
2101
+ const localX = dx * cr - dy_cart * sr;
2102
+ const localY = dx * sr + dy_cart * cr;
2103
+ if (Math.abs(localX) < halfW_px * 1.2 && Math.abs(localY) < halfH_px * 1.2) {
2104
+ const d = Math.sqrt(dx * dx + dy * dy);
2105
+ if (!closestConst || d < minConstDist) {
2106
+ minConstDist = d;
2107
+ closestConst = item;
2108
+ }
2109
+ }
2110
+ }
2111
+ if (closestConst) {
2112
+ const fakeNode = {
2113
+ id: closestConst.config.id,
2114
+ label: closestConst.config.title,
2115
+ level: -1
2116
+ };
2117
+ return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.mesh.position.clone(), index: void 0 };
2118
+ }
2119
+ if (starPoints) {
2120
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
2121
+ raycaster.ray.origin.set(0, 0, 0);
2122
+ raycaster.ray.direction.copy(worldDir);
2123
+ raycaster.params.Points.threshold = 5 * (state.fov / 60);
2124
+ const hits = raycaster.intersectObject(starPoints, false);
2125
+ const pointHit = hits[0];
2126
+ if (pointHit && pointHit.index !== void 0) {
2127
+ const id = starIndexToId[pointHit.index];
2128
+ if (id) {
2129
+ const node = nodeById.get(id);
2130
+ if (node && !isNodeFiltered(node)) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
2131
+ }
1197
2132
  }
1198
2133
  }
1199
2134
  return void 0;
1200
2135
  }
2136
+ function onWindowBlur() {
2137
+ isMouseInWindow = false;
2138
+ edgeHoverStart = 0;
2139
+ }
1201
2140
  function onMouseDown(e) {
1202
2141
  state.lastMouseX = e.clientX;
1203
2142
  state.lastMouseY = e.clientY;
@@ -1221,17 +2160,21 @@ function createEngine({
1221
2160
  if (starId) {
1222
2161
  const starNode = nodeById.get(starId);
1223
2162
  if (starNode && starNode.parent === bookId) {
1224
- children.push({ index: i, initialPos: new THREE4.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
2163
+ children.push({ index: i, initialPos: new THREE5.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
1225
2164
  }
1226
2165
  }
1227
2166
  }
1228
2167
  }
1229
2168
  state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
1230
2169
  state.draggedStarIndex = -1;
2170
+ } else if (hit.type === "constellation") {
2171
+ state.draggedGroup = null;
2172
+ state.draggedStarIndex = -1;
1231
2173
  }
1232
- return;
1233
2174
  }
2175
+ return;
1234
2176
  }
2177
+ flyToActive = false;
1235
2178
  state.dragMode = "camera";
1236
2179
  state.isDragging = true;
1237
2180
  state.velocityX = 0;
@@ -1260,13 +2203,19 @@ function createEngine({
1260
2203
  if (item) {
1261
2204
  item.obj.position.copy(newPos);
1262
2205
  state.tempArrangement[item.node.id] = { position: [newPos.x, newPos.y, newPos.z] };
2206
+ } else if (state.draggedNodeId) {
2207
+ const cItem = constellationLayer.getItems().find((c) => c.config.id === state.draggedNodeId);
2208
+ if (cItem) {
2209
+ cItem.mesh.position.copy(newPos);
2210
+ state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
2211
+ }
1263
2212
  }
1264
2213
  const vStart = group.labelInitialPos.clone().normalize();
1265
2214
  const vEnd = newPos.clone().normalize();
1266
- const q = new THREE4.Quaternion().setFromUnitVectors(vStart, vEnd);
2215
+ const q = new THREE5.Quaternion().setFromUnitVectors(vStart, vEnd);
1267
2216
  if (starPoints && group.children.length > 0) {
1268
2217
  const attr = starPoints.geometry.attributes.position;
1269
- const tempVec = new THREE4.Vector3();
2218
+ const tempVec = new THREE5.Vector3();
1270
2219
  for (const child of group.children) {
1271
2220
  tempVec.copy(child.initialPos).applyQuaternion(q);
1272
2221
  attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
@@ -1284,11 +2233,13 @@ function createEngine({
1284
2233
  state.lastMouseX = e.clientX;
1285
2234
  state.lastMouseY = e.clientY;
1286
2235
  const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2236
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2237
+ const latFactor = 1 - rotLock * rotLock;
1287
2238
  state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1288
- state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2239
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1289
2240
  state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1290
2241
  state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1291
- state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
2242
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
1292
2243
  state.lon = state.targetLon;
1293
2244
  state.lat = state.targetLat;
1294
2245
  } else {
@@ -1300,7 +2251,7 @@ function createEngine({
1300
2251
  if (res) {
1301
2252
  hoverLabelMat.uniforms.uMap.value = res.tex;
1302
2253
  const baseScale = 0.03;
1303
- const size = new THREE4.Vector2(baseScale * res.aspect, baseScale);
2254
+ const size = new THREE5.Vector2(baseScale * res.aspect, baseScale);
1304
2255
  hoverLabelMat.uniforms.uSize.value = size;
1305
2256
  hoverLabelMesh.scale.set(size.x, size.y, 1);
1306
2257
  }
@@ -1316,11 +2267,15 @@ function createEngine({
1316
2267
  if (hit?.node.id !== handlers._lastHoverId) {
1317
2268
  handlers._lastHoverId = hit?.node.id;
1318
2269
  handlers.onHover?.(hit?.node);
2270
+ constellationLayer.setHovered(hit?.node.id ?? null);
1319
2271
  }
1320
2272
  document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
1321
2273
  }
1322
2274
  }
1323
2275
  function onMouseUp(e) {
2276
+ const dx = e.clientX - state.lastMouseX;
2277
+ const dy = e.clientY - state.lastMouseY;
2278
+ const movedDist = Math.sqrt(dx * dx + dy * dy);
1324
2279
  if (state.dragMode === "node") {
1325
2280
  const fullArr = getFullArrangement();
1326
2281
  handlers.onArrangementChange?.(fullArr);
@@ -1333,38 +2288,69 @@ function createEngine({
1333
2288
  state.isDragging = false;
1334
2289
  state.dragMode = "none";
1335
2290
  document.body.style.cursor = "default";
2291
+ if (movedDist < 5) {
2292
+ const hit = pick(e);
2293
+ if (hit) {
2294
+ handlers.onSelect?.(hit.node);
2295
+ constellationLayer.setFocused(hit.node.id);
2296
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2297
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2298
+ } else {
2299
+ setFocusedBook(null);
2300
+ }
2301
+ }
1336
2302
  } else {
1337
2303
  const hit = pick(e);
1338
- if (hit) handlers.onSelect?.(hit.node);
2304
+ if (hit) {
2305
+ handlers.onSelect?.(hit.node);
2306
+ constellationLayer.setFocused(hit.node.id);
2307
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2308
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2309
+ } else {
2310
+ setFocusedBook(null);
2311
+ }
1339
2312
  }
1340
2313
  }
1341
2314
  function onWheel(e) {
1342
2315
  e.preventDefault();
2316
+ flyToActive = false;
1343
2317
  const aspect = container.clientWidth / container.clientHeight;
1344
2318
  renderer.domElement.getBoundingClientRect();
1345
2319
  const vBefore = getMouseViewVector(state.fov, aspect);
1346
2320
  const zoomSpeed = 1e-3 * state.fov;
1347
2321
  state.fov += e.deltaY * zoomSpeed;
1348
2322
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2323
+ handlers.onFovChange?.(state.fov);
1349
2324
  updateUniforms();
1350
2325
  const vAfter = getMouseViewVector(state.fov, aspect);
1351
- const quaternion = new THREE4.Quaternion().setFromUnitVectors(vAfter, vBefore);
2326
+ const quaternion = new THREE5.Quaternion().setFromUnitVectors(vAfter, vBefore);
2327
+ const dampStartFov = 40;
2328
+ const dampEndFov = 120;
2329
+ let spinAmount = 1;
2330
+ if (state.fov > dampStartFov) {
2331
+ const t = Math.max(0, Math.min(1, (state.fov - dampStartFov) / (dampEndFov - dampStartFov)));
2332
+ spinAmount = 1 - Math.pow(t, 1.5) * 0.8;
2333
+ }
2334
+ if (spinAmount < 0.999) {
2335
+ const identityQuat = new THREE5.Quaternion();
2336
+ quaternion.slerp(identityQuat, 1 - spinAmount);
2337
+ }
1352
2338
  const y = Math.sin(state.lat);
1353
2339
  const r = Math.cos(state.lat);
1354
2340
  const x = r * Math.sin(state.lon);
1355
2341
  const z = -r * Math.cos(state.lon);
1356
- const currentLook = new THREE4.Vector3(x, y, z);
2342
+ const currentLook = new THREE5.Vector3(x, y, z);
1357
2343
  const camForward = currentLook.clone().normalize();
1358
2344
  const camUp = camera.up.clone();
1359
- const camRight = new THREE4.Vector3().crossVectors(camForward, camUp).normalize();
1360
- const camUpOrtho = new THREE4.Vector3().crossVectors(camRight, camForward).normalize();
1361
- const mat = new THREE4.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
1362
- const qOld = new THREE4.Quaternion().setFromRotationMatrix(mat);
2345
+ const camRight = new THREE5.Vector3().crossVectors(camForward, camUp).normalize();
2346
+ const camUpOrtho = new THREE5.Vector3().crossVectors(camRight, camForward).normalize();
2347
+ const mat = new THREE5.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
2348
+ const qOld = new THREE5.Quaternion().setFromRotationMatrix(mat);
1363
2349
  const qNew = qOld.clone().multiply(quaternion);
1364
- const newForward = new THREE4.Vector3(0, 0, -1).applyQuaternion(qNew);
2350
+ const newForward = new THREE5.Vector3(0, 0, -1).applyQuaternion(qNew);
1365
2351
  state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
1366
2352
  state.lon = Math.atan2(newForward.x, -newForward.z);
1367
- const newUp = new THREE4.Vector3(0, 1, 0).applyQuaternion(qNew);
2353
+ const newUp = new THREE5.Vector3(0, 1, 0).applyQuaternion(qNew);
1368
2354
  camera.up.copy(newUp);
1369
2355
  if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
1370
2356
  const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
@@ -1397,67 +2383,144 @@ function createEngine({
1397
2383
  el.addEventListener("mouseenter", () => {
1398
2384
  isMouseInWindow = true;
1399
2385
  });
1400
- el.addEventListener("mouseleave", () => {
1401
- isMouseInWindow = false;
1402
- });
2386
+ el.addEventListener("mouseleave", onWindowBlur);
2387
+ window.addEventListener("blur", onWindowBlur);
1403
2388
  raf = requestAnimationFrame(tick);
1404
2389
  }
1405
2390
  function tick() {
1406
2391
  if (!running) return;
1407
2392
  raf = requestAnimationFrame(tick);
1408
- if (!state.isDragging && isMouseInWindow) {
2393
+ const now = performance.now();
2394
+ globalUniforms.uTime.value = now / 1e3;
2395
+ let activeId = null;
2396
+ if (focusedBookId) {
2397
+ activeId = focusedBookId;
2398
+ } else if (hoveredBookId) {
2399
+ const lastExit = hoverCooldowns.get(hoveredBookId) || 0;
2400
+ if (now - lastExit > COOLDOWN_MS) {
2401
+ activeId = hoveredBookId;
2402
+ }
2403
+ }
2404
+ const targetStrength = orderRevealEnabled && activeId ? 1 : 0;
2405
+ orderRevealStrength = mix(orderRevealStrength, targetStrength, 0.1);
2406
+ if (orderRevealStrength > 1e-3 || targetStrength > 0) {
2407
+ if (activeId && bookIdToIndex.has(activeId)) {
2408
+ activeBookIndex = bookIdToIndex.get(activeId);
2409
+ }
2410
+ if (starPoints && starPoints.material) {
2411
+ const m = starPoints.material;
2412
+ if (m.uniforms.uActiveBookIndex) m.uniforms.uActiveBookIndex.value = activeBookIndex;
2413
+ if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2414
+ }
2415
+ }
2416
+ const filterTarget = currentFilter ? 1 : 0;
2417
+ filterStrength = mix(filterStrength, filterTarget, 0.1);
2418
+ if (filterStrength > 1e-3 || filterTarget > 0) {
2419
+ if (starPoints && starPoints.material) {
2420
+ const m = starPoints.material;
2421
+ if (m.uniforms.uFilterTestamentIndex) m.uniforms.uFilterTestamentIndex.value = filterTestamentIndex;
2422
+ if (m.uniforms.uFilterDivisionIndex) m.uniforms.uFilterDivisionIndex.value = filterDivisionIndex;
2423
+ if (m.uniforms.uFilterBookIndex) m.uniforms.uFilterBookIndex.value = filterBookIndex;
2424
+ if (m.uniforms.uFilterStrength) m.uniforms.uFilterStrength.value = filterStrength;
2425
+ }
2426
+ }
2427
+ let panX = 0;
2428
+ let panY = 0;
2429
+ if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
1409
2430
  const t = ENGINE_CONFIG.edgePanThreshold;
1410
- const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
1411
- let panX = 0;
1412
- let panY = 0;
1413
- if (mouseNDC.x < -1 + t) {
1414
- const s = (-1 + t - mouseNDC.x) / t;
1415
- panX = -s * s * speedBase;
1416
- } else if (mouseNDC.x > 1 - t) {
1417
- const s = (mouseNDC.x - (1 - t)) / t;
1418
- panX = s * s * speedBase;
1419
- }
1420
- if (mouseNDC.y < -1 + t) {
1421
- const s = (-1 + t - mouseNDC.y) / t;
1422
- panY = -s * s * speedBase;
1423
- } else if (mouseNDC.y > 1 - t) {
1424
- const s = (mouseNDC.y - (1 - t)) / t;
1425
- panY = s * s * speedBase;
1426
- }
1427
- if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
1428
- state.lon += panX;
1429
- state.lat += panY;
1430
- state.targetLon = state.lon;
1431
- state.targetLat = state.lat;
2431
+ const inZoneX = mouseNDC.x < -1 + t || mouseNDC.x > 1 - t;
2432
+ const inZoneY = mouseNDC.y < -1 + t || mouseNDC.y > 1 - t;
2433
+ if (inZoneX || inZoneY) {
2434
+ if (edgeHoverStart === 0) edgeHoverStart = performance.now();
2435
+ if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
2436
+ const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
2437
+ if (mouseNDC.x < -1 + t) {
2438
+ const s = (-1 + t - mouseNDC.x) / t;
2439
+ panX = -s * s * speedBase;
2440
+ } else if (mouseNDC.x > 1 - t) {
2441
+ const s = (mouseNDC.x - (1 - t)) / t;
2442
+ panX = s * s * speedBase;
2443
+ }
2444
+ if (mouseNDC.y < -1 + t) {
2445
+ const s = (-1 + t - mouseNDC.y) / t;
2446
+ panY = -s * s * speedBase;
2447
+ } else if (mouseNDC.y > 1 - t) {
2448
+ const s = (mouseNDC.y - (1 - t)) / t;
2449
+ panY = s * s * speedBase;
2450
+ }
2451
+ }
1432
2452
  } else {
1433
- state.lon += state.velocityX;
1434
- state.lat += state.velocityY;
1435
- state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1436
- state.velocityY *= ENGINE_CONFIG.inertiaDamping;
1437
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
1438
- if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
1439
- }
1440
- } else if (!state.isDragging) {
2453
+ edgeHoverStart = 0;
2454
+ }
2455
+ } else {
2456
+ edgeHoverStart = 0;
2457
+ }
2458
+ if (flyToActive && !state.isDragging) {
2459
+ state.lon = mix(state.lon, flyToTargetLon, FLY_TO_SPEED);
2460
+ state.lat = mix(state.lat, flyToTargetLat, FLY_TO_SPEED);
2461
+ state.fov = mix(state.fov, flyToTargetFov, FLY_TO_SPEED);
2462
+ state.targetLon = state.lon;
2463
+ state.targetLat = state.lat;
2464
+ state.velocityX = 0;
2465
+ state.velocityY = 0;
2466
+ handlers.onFovChange?.(state.fov);
2467
+ if (Math.abs(state.lon - flyToTargetLon) < 1e-4 && Math.abs(state.lat - flyToTargetLat) < 1e-4 && Math.abs(state.fov - flyToTargetFov) < 0.05) {
2468
+ flyToActive = false;
2469
+ state.lon = flyToTargetLon;
2470
+ state.lat = flyToTargetLat;
2471
+ state.fov = flyToTargetFov;
2472
+ }
2473
+ }
2474
+ if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2475
+ state.lon += panX;
2476
+ state.lat += panY;
2477
+ state.targetLon = state.lon;
2478
+ state.targetLat = state.lat;
2479
+ } else if (!state.isDragging && !flyToActive) {
1441
2480
  state.lon += state.velocityX;
1442
2481
  state.lat += state.velocityY;
1443
2482
  state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1444
2483
  state.velocityY *= ENGINE_CONFIG.inertiaDamping;
2484
+ if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2485
+ if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
1445
2486
  }
1446
2487
  state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
1447
2488
  const y = Math.sin(state.lat);
1448
2489
  const r = Math.cos(state.lat);
1449
2490
  const x = r * Math.sin(state.lon);
1450
2491
  const z = -r * Math.cos(state.lon);
1451
- const target = new THREE4.Vector3(x, y, z);
1452
- const idealUp = new THREE4.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
2492
+ const target = new THREE5.Vector3(x, y, z);
2493
+ const idealUp = new THREE5.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
1453
2494
  camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
1454
2495
  camera.up.normalize();
1455
2496
  camera.lookAt(target);
2497
+ camera.updateMatrixWorld();
2498
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
1456
2499
  updateUniforms();
2500
+ const nowSec = now / 1e3;
2501
+ const dt = lastTickTime > 0 ? Math.min(nowSec - lastTickTime, 0.1) : 0.016;
2502
+ lastTickTime = nowSec;
2503
+ linesFader.target = currentConfig?.showConstellationLines ?? false;
2504
+ linesFader.update(dt);
2505
+ artFader.target = currentConfig?.showConstellationArt ?? false;
2506
+ artFader.update(dt);
2507
+ constellationLayer.update(state.fov, artFader.eased > 0.01);
2508
+ if (artFader.eased < 1) {
2509
+ constellationLayer.setGlobalOpacity?.(artFader.eased);
2510
+ }
2511
+ backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2512
+ if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
1457
2513
  const DIVISION_THRESHOLD = 60;
1458
2514
  const showDivisions = state.fov > DIVISION_THRESHOLD;
1459
2515
  if (constellationLines) {
1460
- constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2516
+ constellationLines.visible = linesFader.eased > 0.01;
2517
+ if (constellationLines.visible && constellationLines.material) {
2518
+ const mat = constellationLines.material;
2519
+ if (mat.uniforms?.color) {
2520
+ mat.uniforms.color.value.setHex(11193599);
2521
+ mat.opacity = linesFader.eased;
2522
+ }
2523
+ }
1461
2524
  }
1462
2525
  if (boundaryLines) {
1463
2526
  boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
@@ -1477,7 +2540,8 @@ function createEngine({
1477
2540
  const showBookLabels = currentConfig?.showBookLabels === true;
1478
2541
  const showDivisionLabels = currentConfig?.showDivisionLabels === true;
1479
2542
  const showChapterLabels = currentConfig?.showChapterLabels === true;
1480
- const showChapters = state.fov < 35;
2543
+ const showGroupLabels = currentConfig?.showGroupLabels === true;
2544
+ const showChapters = state.fov < 45;
1481
2545
  for (const item of dynamicLabels) {
1482
2546
  const uniforms = item.obj.material.uniforms;
1483
2547
  const level = item.node.level;
@@ -1485,20 +2549,21 @@ function createEngine({
1485
2549
  if (level === 2 && showBookLabels) isEnabled = true;
1486
2550
  else if (level === 1 && showDivisionLabels) isEnabled = true;
1487
2551
  else if (level === 3 && showChapterLabels) isEnabled = true;
2552
+ else if (level === 2.5 && showGroupLabels) isEnabled = true;
1488
2553
  if (!isEnabled) {
1489
- uniforms.uAlpha.value = THREE4.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2554
+ uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1490
2555
  item.obj.visible = uniforms.uAlpha.value > 0.01;
1491
2556
  continue;
1492
2557
  }
1493
2558
  const pWorld = item.obj.position;
1494
2559
  const pProj = smartProjectJS(pWorld);
1495
2560
  if (pProj.z > 0.2) {
1496
- uniforms.uAlpha.value = THREE4.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2561
+ uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1497
2562
  item.obj.visible = uniforms.uAlpha.value > 0.01;
1498
2563
  continue;
1499
2564
  }
1500
- if (level === 3 && !showChapters && item.node.id !== state.draggedNodeId) {
1501
- uniforms.uAlpha.value = THREE4.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2565
+ if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2566
+ uniforms.uAlpha.value = THREE5.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1502
2567
  item.obj.visible = uniforms.uAlpha.value > 0.01;
1503
2568
  continue;
1504
2569
  }
@@ -1509,7 +2574,7 @@ function createEngine({
1509
2574
  const size = uniforms.uSize.value;
1510
2575
  const pixelH = size.y * screenH * 0.8;
1511
2576
  const pixelW = size.x * screenH * 0.8;
1512
- labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level });
2577
+ labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level, ndcX, ndcY });
1513
2578
  }
1514
2579
  const hoverId = handlers._lastHoverId;
1515
2580
  const selectedId = state.draggedNodeId;
@@ -1529,17 +2594,19 @@ function createEngine({
1529
2594
  const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
1530
2595
  if (l.level === 1) {
1531
2596
  let rot = 0;
1532
- const blend = globalUniforms.uBlend.value;
1533
- if (blend > 0.5) {
2597
+ const isWideAngle = currentProjection.id !== "perspective";
2598
+ if (isWideAngle) {
1534
2599
  const dx = l.sX - screenW / 2;
1535
2600
  const dy = l.sY - screenH / 2;
1536
2601
  rot = Math.atan2(-dy, -dx) - Math.PI / 2;
1537
2602
  }
1538
- l.uniforms.uAngle.value = THREE4.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2603
+ l.uniforms.uAngle.value = THREE5.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
1539
2604
  }
1540
2605
  if (l.level === 2) {
1541
- target2 = 1;
1542
- occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2606
+ {
2607
+ target2 = 1;
2608
+ occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2609
+ }
1543
2610
  } else if (l.level === 1) {
1544
2611
  if (showDivisions || isSpecial) {
1545
2612
  const pad = -5;
@@ -1548,12 +2615,28 @@ function createEngine({
1548
2615
  occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
1549
2616
  }
1550
2617
  }
1551
- } else if (l.level === 3) {
2618
+ } else if (l.level === 2.5 || l.level === 3) {
1552
2619
  if (showChapters || isSpecial) {
1553
2620
  target2 = 1;
2621
+ if (!isSpecial) {
2622
+ const dist = Math.sqrt(l.ndcX * l.ndcX + l.ndcY * l.ndcY);
2623
+ const focusFade = 1 - THREE5.MathUtils.smoothstep(0.4, 0.7, dist);
2624
+ target2 *= focusFade;
2625
+ }
2626
+ }
2627
+ }
2628
+ if (target2 > 0 && currentFilter && filterStrength > 0.01) {
2629
+ const node = l.item.node;
2630
+ if (node.level === 3) {
2631
+ target2 = 0;
2632
+ } else if (node.level === 2 || node.level === 2.5) {
2633
+ const nodeToCheck = node.level === 2.5 && node.parent ? nodeById.get(node.parent) : node;
2634
+ if (nodeToCheck && isNodeFiltered(nodeToCheck)) {
2635
+ target2 = 0;
2636
+ }
1554
2637
  }
1555
2638
  }
1556
- l.uniforms.uAlpha.value = THREE4.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2639
+ l.uniforms.uAlpha.value = THREE5.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
1557
2640
  l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
1558
2641
  }
1559
2642
  renderer.render(scene, camera);
@@ -1567,41 +2650,96 @@ function createEngine({
1567
2650
  window.removeEventListener("mousemove", onMouseMove);
1568
2651
  window.removeEventListener("mouseup", onMouseUp);
1569
2652
  el.removeEventListener("wheel", onWheel);
2653
+ el.removeEventListener("mouseleave", onWindowBlur);
2654
+ window.removeEventListener("blur", onWindowBlur);
1570
2655
  }
1571
2656
  function dispose() {
1572
2657
  stop();
2658
+ constellationLayer.dispose();
1573
2659
  renderer.dispose();
1574
2660
  renderer.domElement.remove();
1575
2661
  }
1576
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement };
2662
+ function setHoveredBook(id) {
2663
+ if (id === hoveredBookId) return;
2664
+ if (hoveredBookId) {
2665
+ hoverCooldowns.set(hoveredBookId, performance.now());
2666
+ }
2667
+ hoveredBookId = id;
2668
+ }
2669
+ function setFocusedBook(id) {
2670
+ focusedBookId = id;
2671
+ }
2672
+ function setOrderRevealEnabled(enabled) {
2673
+ orderRevealEnabled = enabled;
2674
+ }
2675
+ function flyTo(nodeId, targetFov) {
2676
+ const node = nodeById.get(nodeId);
2677
+ if (!node) return;
2678
+ const pos = getPosition(node).normalize();
2679
+ flyToTargetLat = Math.asin(Math.max(-0.999, Math.min(0.999, pos.y)));
2680
+ flyToTargetLon = Math.atan2(pos.x, -pos.z);
2681
+ flyToTargetFov = targetFov ?? ENGINE_CONFIG.minFov;
2682
+ flyToActive = true;
2683
+ state.velocityX = 0;
2684
+ state.velocityY = 0;
2685
+ }
2686
+ function setHierarchyFilter(filter) {
2687
+ currentFilter = filter;
2688
+ if (filter) {
2689
+ filterTestamentIndex = filter.testament && testamentToIndex.has(filter.testament) ? testamentToIndex.get(filter.testament) : -1;
2690
+ filterDivisionIndex = filter.division && divisionToIndex.has(filter.division) ? divisionToIndex.get(filter.division) : -1;
2691
+ filterBookIndex = filter.bookKey && bookIdToIndex.has(`B:${filter.bookKey}`) ? bookIdToIndex.get(`B:${filter.bookKey}`) : -1;
2692
+ } else {
2693
+ filterTestamentIndex = -1;
2694
+ filterDivisionIndex = -1;
2695
+ filterBookIndex = -1;
2696
+ }
2697
+ }
2698
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled, setHierarchyFilter, flyTo, setProjection };
1577
2699
  }
1578
- var ENGINE_CONFIG;
2700
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
1579
2701
  var init_createEngine = __esm({
1580
2702
  "src/engine/createEngine.ts"() {
1581
2703
  init_layout();
1582
2704
  init_materials();
2705
+ init_ConstellationArtworkLayer();
2706
+ init_projections();
2707
+ init_fader();
1583
2708
  ENGINE_CONFIG = {
1584
2709
  minFov: 10,
1585
- maxFov: 165,
1586
- defaultFov: 80,
2710
+ maxFov: 135,
2711
+ defaultFov: 50,
1587
2712
  dragSpeed: 125e-5,
1588
2713
  inertiaDamping: 0.92,
1589
- blendStart: 60,
1590
- blendEnd: 165,
1591
- zenithStartFov: 110,
1592
- zenithStrength: 0.02,
2714
+ blendStart: 35,
2715
+ blendEnd: 83,
2716
+ zenithStartFov: 75,
2717
+ zenithStrength: 0.15,
1593
2718
  horizonLockStrength: 0.05,
1594
2719
  edgePanThreshold: 0.15,
1595
- edgePanMaxSpeed: 0.02
2720
+ edgePanMaxSpeed: 0.02,
2721
+ edgePanDelay: 250
2722
+ };
2723
+ ORDER_REVEAL_CONFIG = {
2724
+ globalDim: 0.85,
2725
+ pulseAmplitude: 0.6,
2726
+ pulseDuration: 2,
2727
+ delayPerChapter: 0.1
1596
2728
  };
1597
2729
  }
1598
2730
  });
1599
2731
  var StarMap = forwardRef(
1600
- ({ config, className, onSelect, onHover, onArrangementChange }, ref) => {
2732
+ ({ config, className, onSelect, onHover, onArrangementChange, onFovChange }, ref) => {
1601
2733
  const containerRef = useRef(null);
1602
2734
  const engineRef = useRef(null);
1603
2735
  useImperativeHandle(ref, () => ({
1604
- getFullArrangement: () => engineRef.current?.getFullArrangement?.()
2736
+ getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2737
+ setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2738
+ setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2739
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled),
2740
+ setHierarchyFilter: (filter) => engineRef.current?.setHierarchyFilter?.(filter),
2741
+ flyTo: (nodeId, targetFov) => engineRef.current?.flyTo?.(nodeId, targetFov),
2742
+ setProjection: (id) => engineRef.current?.setProjection?.(id)
1605
2743
  }));
1606
2744
  useEffect(() => {
1607
2745
  let disposed = false;
@@ -1613,7 +2751,8 @@ var StarMap = forwardRef(
1613
2751
  container: containerRef.current,
1614
2752
  onSelect,
1615
2753
  onHover,
1616
- onArrangementChange
2754
+ onArrangementChange,
2755
+ onFovChange
1617
2756
  });
1618
2757
  engineRef.current.setConfig(config);
1619
2758
  engineRef.current.start();
@@ -1629,8 +2768,8 @@ var StarMap = forwardRef(
1629
2768
  engineRef.current?.setConfig?.(config);
1630
2769
  }, [config]);
1631
2770
  useEffect(() => {
1632
- engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange });
1633
- }, [onSelect, onHover, onArrangementChange]);
2771
+ engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange, onFovChange });
2772
+ }, [onSelect, onHover, onArrangementChange, onFovChange]);
1634
2773
  return /* @__PURE__ */ jsx("div", { ref: containerRef, className, style: { width: "100%", height: "100%" } });
1635
2774
  }
1636
2775
  );
@@ -1639,7 +2778,6 @@ var StarMap = forwardRef(
1639
2778
  function bibleToSceneModel(data) {
1640
2779
  const nodes = [];
1641
2780
  const links = [];
1642
- let bookCounter = 0;
1643
2781
  const id = {
1644
2782
  testament: (t) => `T:${t}`,
1645
2783
  division: (t, d) => `D:${t}:${d}`,
@@ -1660,8 +2798,7 @@ function bibleToSceneModel(data) {
1660
2798
  });
1661
2799
  links.push({ source: did, target: tid });
1662
2800
  for (const b of d.books) {
1663
- bookCounter++;
1664
- const bookLabel = `${bookCounter}. ${b.name}`;
2801
+ const bookLabel = b.name;
1665
2802
  const bid = id.book(b.key);
1666
2803
  nodes.push({
1667
2804
  id: bid,
@@ -30774,7 +31911,7 @@ var RNG = class {
30774
31911
  const r = Math.sqrt(1 - y * y);
30775
31912
  const x = r * Math.cos(theta);
30776
31913
  const z = r * Math.sin(theta);
30777
- return new THREE4.Vector3(x, y, z);
31914
+ return new THREE5.Vector3(x, y, z);
30778
31915
  }
30779
31916
  };
30780
31917
  function simpleNoise3D(v, scale) {
@@ -30812,11 +31949,11 @@ function generateArrangement(bible, options = {}) {
30812
31949
  });
30813
31950
  });
30814
31951
  const bookCount = books.length;
30815
- const mwRad = THREE4.MathUtils.degToRad(opts.milkyWayAngle);
30816
- const mwNormal = new THREE4.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
31952
+ const mwRad = THREE5.MathUtils.degToRad(opts.milkyWayAngle);
31953
+ const mwNormal = new THREE5.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
30817
31954
  const anchors = [];
30818
31955
  for (let i = 0; i < bookCount; i++) {
30819
- let bestP = new THREE4.Vector3();
31956
+ let bestP = new THREE5.Vector3();
30820
31957
  let valid = false;
30821
31958
  let attempt = 0;
30822
31959
  while (!valid && attempt < 100) {
@@ -30842,7 +31979,7 @@ function generateArrangement(bible, options = {}) {
30842
31979
  arrangement[`B:${book.key}`] = { position: [anchorPos.x, anchorPos.y, anchorPos.z] };
30843
31980
  for (let c = 0; c < book.chapters; c++) {
30844
31981
  const localSpread = opts.clusterSpread * (0.8 + rng.next() * 0.4);
30845
- const offset = new THREE4.Vector3(
31982
+ const offset = new THREE5.Vector3(
30846
31983
  (rng.next() - 0.5) * 2,
30847
31984
  (rng.next() - 0.5) * 2,
30848
31985
  (rng.next() - 0.5) * 2
@@ -30863,7 +32000,7 @@ function generateArrangement(bible, options = {}) {
30863
32000
  const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
30864
32001
  const divId = `D:${book.testament}:${book.division}`;
30865
32002
  if (!divisions.has(divId)) {
30866
- divisions.set(divId, { sum: new THREE4.Vector3(), count: 0 });
32003
+ divisions.set(divId, { sum: new THREE5.Vector3(), count: 0 });
30867
32004
  }
30868
32005
  const entry = divisions.get(divId);
30869
32006
  entry.sum.add(anchorPos);
@@ -30879,6 +32016,9 @@ function generateArrangement(bible, options = {}) {
30879
32016
  return arrangement;
30880
32017
  }
30881
32018
 
30882
- export { StarMap, bibleToSceneModel, defaultGenerateOptions, default_stars_default as defaultStars, generateArrangement };
32019
+ // src/index.ts
32020
+ init_projections();
32021
+
32022
+ export { PROJECTIONS, StarMap, bibleToSceneModel, defaultGenerateOptions, default_stars_default as defaultStars, generateArrangement };
30883
32023
  //# sourceMappingURL=index.js.map
30884
32024
  //# sourceMappingURL=index.js.map