@project-skymap/library 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var THREE4 = require('three');
3
+ var THREE5 = require('three');
4
4
  var react = require('react');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
 
@@ -22,7 +22,7 @@ function _interopNamespace(e) {
22
22
  return Object.freeze(n);
23
23
  }
24
24
 
25
- var THREE4__namespace = /*#__PURE__*/_interopNamespace(THREE4);
25
+ var THREE5__namespace = /*#__PURE__*/_interopNamespace(THREE5);
26
26
 
27
27
  var __defProp = Object.defineProperty;
28
28
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -151,14 +151,14 @@ var init_constellations = __esm({
151
151
  });
152
152
  function lookAt(point, target, up) {
153
153
  const zAxis = target.clone().normalize();
154
- let xAxis = new THREE4__namespace.Vector3().crossVectors(up, zAxis);
154
+ let xAxis = new THREE5__namespace.Vector3().crossVectors(up, zAxis);
155
155
  if (xAxis.lengthSq() < 1e-4) {
156
- xAxis = new THREE4__namespace.Vector3().crossVectors(new THREE4__namespace.Vector3(1, 0, 0), zAxis);
156
+ xAxis = new THREE5__namespace.Vector3().crossVectors(new THREE5__namespace.Vector3(1, 0, 0), zAxis);
157
157
  }
158
158
  xAxis.normalize();
159
- const yAxis = new THREE4__namespace.Vector3().crossVectors(zAxis, xAxis).normalize();
160
- const m = new THREE4__namespace.Matrix4().makeBasis(xAxis, yAxis, zAxis);
161
- const v = new THREE4__namespace.Vector3(point.x, point.y, point.z);
159
+ const yAxis = new THREE5__namespace.Vector3().crossVectors(zAxis, xAxis).normalize();
160
+ const m = new THREE5__namespace.Matrix4().makeBasis(xAxis, yAxis, zAxis);
161
+ const v = new THREE5__namespace.Vector3(point.x, point.y, point.z);
162
162
  v.applyMatrix4(m);
163
163
  v.add(target);
164
164
  return { x: v.x, y: v.y, z: v.z };
@@ -239,7 +239,7 @@ function computeLayoutPositions(model, layout) {
239
239
  const radiusAtY = Math.sqrt(1 - y * y);
240
240
  const x = Math.cos(midAngle) * radiusAtY;
241
241
  const z = Math.sin(midAngle) * radiusAtY;
242
- const labelPos = new THREE4__namespace.Vector3(x, y, z).multiplyScalar(radius);
242
+ const labelPos = new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
243
243
  uDivision.meta.x = labelPos.x;
244
244
  uDivision.meta.y = labelPos.y;
245
245
  uDivision.meta.z = labelPos.z;
@@ -255,7 +255,7 @@ function computeLayoutPositions(model, layout) {
255
255
  const theta = startAngle + t * angleSpan;
256
256
  const x = Math.cos(theta) * radiusAtY;
257
257
  const z = Math.sin(theta) * radiusAtY;
258
- const bookPos = new THREE4__namespace.Vector3(x, y, z).multiplyScalar(radius);
258
+ const bookPos = new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
259
259
  const labelPos = bookPos.clone();
260
260
  labelPos.y += radius * 0.025;
261
261
  labelPos.setLength(radius);
@@ -266,7 +266,7 @@ function computeLayoutPositions(model, layout) {
266
266
  if (chapters.length > 0) {
267
267
  const territoryRadius = radius * 2 / Math.sqrt(books.length * 2) * 0.7;
268
268
  const localPoints = getConstellationLayout(bookKey, chapters.length, territoryRadius);
269
- const up = new THREE4__namespace.Vector3(0, 1, 0);
269
+ const up = new THREE5__namespace.Vector3(0, 1, 0);
270
270
  chapters.forEach((chap, idx) => {
271
271
  const uChap = updatedNodeMap.get(chap.id);
272
272
  const lp = localPoints[idx];
@@ -285,10 +285,10 @@ function computeLayoutPositions(model, layout) {
285
285
  testaments.forEach((t) => {
286
286
  const children = childrenMap.get(t.id) ?? [];
287
287
  if (children.length === 0) return;
288
- const centroid = new THREE4__namespace.Vector3();
288
+ const centroid = new THREE5__namespace.Vector3();
289
289
  children.forEach((c) => {
290
290
  const u = updatedNodeMap.get(c.id);
291
- centroid.add(new THREE4__namespace.Vector3(u.meta.x, u.meta.y, u.meta.z));
291
+ centroid.add(new THREE5__namespace.Vector3(u.meta.x, u.meta.y, u.meta.z));
292
292
  });
293
293
  centroid.divideScalar(children.length);
294
294
  if (centroid.length() > 0.1) {
@@ -352,11 +352,18 @@ vec4 smartProject(vec4 viewPos) {
352
352
  vec2 projected = vec2(k * dir.x, k * dir.y);
353
353
  projected *= uScale;
354
354
  projected.x /= uAspect;
355
- float zMetric = -1.0 + (dist / 2000.0);
355
+ float zMetric = -1.0 + (dist / 15000.0);
356
+
357
+ // Radial Clipping: Push clipped points off-screen in their natural direction
358
+ // to prevent lines "darting" across the center.
359
+ vec2 escapeDir = (length(dir.xy) > 0.0001) ? normalize(dir.xy) : vec2(1.0, 1.0);
360
+ vec2 escapePos = escapeDir * 10000.0;
361
+
356
362
  // Clip backward facing points in fisheye mode
357
- if (uBlend > 0.5 && dir.z > 0.4) return vec4(10.0, 10.0, 10.0, 1.0);
363
+ if (uBlend > 0.5 && dir.z > 0.4) return vec4(escapePos, 10.0, 1.0);
358
364
  // Clip very close points in linear mode
359
- if (uBlend < 0.1 && dir.z > -0.1) return vec4(10.0, 10.0, 10.0, 1.0);
365
+ if (uBlend < 0.1 && dir.z > -0.1) return vec4(escapePos, 10.0, 1.0);
366
+
360
367
  return vec4(projected, zMetric, 1.0);
361
368
  }
362
369
  `;
@@ -379,7 +386,7 @@ float getMaskAlpha() {
379
386
  });
380
387
  function createSmartMaterial(params) {
381
388
  const uniforms = { ...globalUniforms, ...params.uniforms };
382
- return new THREE4__namespace.ShaderMaterial({
389
+ return new THREE5__namespace.ShaderMaterial({
383
390
  uniforms,
384
391
  vertexShader: `
385
392
  ${BLEND_CHUNK}
@@ -393,8 +400,8 @@ function createSmartMaterial(params) {
393
400
  transparent: params.transparent || false,
394
401
  depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
395
402
  depthTest: params.depthTest !== void 0 ? params.depthTest : true,
396
- side: params.side || THREE4__namespace.FrontSide,
397
- blending: params.blending || THREE4__namespace.NormalBlending
403
+ side: params.side || THREE5__namespace.FrontSide,
404
+ blending: params.blending || THREE5__namespace.NormalBlending
398
405
  });
399
406
  }
400
407
  var globalUniforms;
@@ -404,7 +411,248 @@ var init_materials = __esm({
404
411
  globalUniforms = {
405
412
  uScale: { value: 1 },
406
413
  uAspect: { value: 1 },
407
- uBlend: { value: 0 }
414
+ uBlend: { value: 0 },
415
+ uTime: { value: 0 },
416
+ // Atmosphere Settings
417
+ uAtmGlow: { value: 1 },
418
+ uAtmDark: { value: 0.6 },
419
+ uAtmExtinction: { value: 4 },
420
+ uAtmTwinkle: { value: 0.8 },
421
+ uColorHorizon: { value: new THREE5__namespace.Color(2768476) },
422
+ uColorZenith: { value: new THREE5__namespace.Color(132104) }
423
+ };
424
+ }
425
+ });
426
+ var ConstellationArtworkLayer;
427
+ var init_ConstellationArtworkLayer = __esm({
428
+ "src/engine/ConstellationArtworkLayer.ts"() {
429
+ init_materials();
430
+ ConstellationArtworkLayer = class {
431
+ root;
432
+ items = [];
433
+ textureLoader = new THREE5__namespace.TextureLoader();
434
+ hoveredId = null;
435
+ focusedId = null;
436
+ constructor(root) {
437
+ this.root = new THREE5__namespace.Group();
438
+ this.root.renderOrder = -1;
439
+ root.add(this.root);
440
+ }
441
+ getItems() {
442
+ return this.items;
443
+ }
444
+ setPosition(id, pos) {
445
+ const item = this.items.find((i) => i.config.id === id);
446
+ if (item) {
447
+ item.mesh.position.copy(pos);
448
+ }
449
+ }
450
+ load(config, getPosition) {
451
+ this.clear();
452
+ const basePath = config.atlasBasePath.replace(/\/$/, "");
453
+ config.constellations.forEach((c) => {
454
+ let center = new THREE5__namespace.Vector3();
455
+ let valid = false;
456
+ let radius = 2e3;
457
+ const arrPos = getPosition(c.id);
458
+ if (arrPos) {
459
+ center.copy(arrPos);
460
+ valid = true;
461
+ if (c.anchors.length > 0) {
462
+ const points = [];
463
+ for (const anchorId of c.anchors) {
464
+ const p = getPosition(anchorId);
465
+ if (p) points.push(p);
466
+ }
467
+ if (points.length > 0) {
468
+ radius = points[0].length();
469
+ }
470
+ }
471
+ } else if (c.center) {
472
+ center.set(c.center[0], c.center[1], c.center[2]);
473
+ valid = true;
474
+ } else if (c.anchors.length > 0) {
475
+ const points = [];
476
+ for (const anchorId of c.anchors) {
477
+ const p = getPosition(anchorId);
478
+ if (p) points.push(p);
479
+ }
480
+ if (points.length > 0) {
481
+ for (const p of points) center.add(p);
482
+ center.divideScalar(points.length);
483
+ const len = center.length();
484
+ if (len > 1e-3) {
485
+ radius = points[0].length();
486
+ center.normalize().multiplyScalar(radius);
487
+ }
488
+ valid = true;
489
+ }
490
+ }
491
+ if (!valid) return;
492
+ const normal = center.clone().normalize().negate();
493
+ const upVec = center.clone().normalize();
494
+ let right = new THREE5__namespace.Vector3(1, 0, 0);
495
+ if (c.anchors.length >= 2) {
496
+ const p0 = getPosition(c.anchors[0]);
497
+ const p1 = getPosition(c.anchors[1]);
498
+ if (p0 && p1) {
499
+ const diff = new THREE5__namespace.Vector3().subVectors(p1, p0);
500
+ right.copy(diff).sub(upVec.clone().multiplyScalar(diff.dot(upVec))).normalize();
501
+ }
502
+ } else {
503
+ if (Math.abs(upVec.y) > 0.9) right.set(1, 0, 0).cross(upVec).normalize();
504
+ else right.set(0, 1, 0).cross(upVec).normalize();
505
+ }
506
+ const top = new THREE5__namespace.Vector3().crossVectors(upVec, right).normalize();
507
+ right.crossVectors(top, upVec).normalize();
508
+ new THREE5__namespace.Matrix4().makeBasis(right, top, normal);
509
+ const geometry = new THREE5__namespace.PlaneGeometry(1, 1);
510
+ let size = c.radius;
511
+ if (size <= 1) size *= radius;
512
+ size *= 2;
513
+ const texPath = `${basePath}/${c.image}`;
514
+ let blending = THREE5__namespace.NormalBlending;
515
+ if (c.blend === "additive") blending = THREE5__namespace.AdditiveBlending;
516
+ const material = createSmartMaterial({
517
+ uniforms: {
518
+ uMap: { value: this.textureLoader.load(texPath) },
519
+ // Placeholder, updated below
520
+ uOpacity: { value: c.opacity },
521
+ uSize: { value: size },
522
+ uImgRotation: { value: THREE5__namespace.MathUtils.degToRad(c.rotationDeg) },
523
+ uImgAspect: { value: c.aspectRatio ?? 1 }
524
+ // uScale, uAspect (screen) are injected by createSmartMaterial/globalUniforms
525
+ },
526
+ vertexShaderBody: `
527
+ uniform float uSize;
528
+ uniform float uImgRotation;
529
+ uniform float uImgAspect;
530
+
531
+ varying vec2 vUv;
532
+
533
+ void main() {
534
+ vUv = uv;
535
+
536
+ // 1. Project Center Point (Proven Method)
537
+ vec4 mvCenter = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
538
+ vec4 clipCenter = smartProject(mvCenter);
539
+
540
+ // 2. Project "Up" Point (World Zenith)
541
+ // Transform World Up (0,1,0) to View Space
542
+ vec3 viewUpDir = mat3(viewMatrix) * vec3(0.0, 1.0, 0.0);
543
+ // Offset center by a significant amount (1000.0) to ensure screen delta
544
+ vec4 mvUp = mvCenter + vec4(viewUpDir * 1000.0, 0.0);
545
+ vec4 clipUp = smartProject(mvUp);
546
+
547
+ // 3. Calculate Horizon Angle
548
+ vec2 screenCenter = clipCenter.xy / clipCenter.w;
549
+ vec2 screenUp = clipUp.xy / clipUp.w;
550
+ vec2 screenDelta = screenUp - screenCenter;
551
+
552
+ float horizonAngle = 0.0;
553
+ if (length(screenDelta) > 0.001) {
554
+ vec2 screenDir = normalize(screenDelta);
555
+ horizonAngle = atan(screenDir.y, screenDir.x) - 1.5708; // -90 deg
556
+ }
557
+
558
+ // 4. Combine with User Rotation
559
+ float finalAngle = uImgRotation + horizonAngle;
560
+
561
+ // 5. Billboard Offset
562
+ vec2 offset = position.xy;
563
+
564
+ float cr = cos(finalAngle);
565
+ float sr = sin(finalAngle);
566
+ vec2 rotated = vec2(
567
+ offset.x * cr - offset.y * sr,
568
+ offset.x * sr + offset.y * cr
569
+ );
570
+
571
+ rotated.x *= uImgAspect;
572
+
573
+ float dist = length(mvCenter.xyz);
574
+ float scale = (uSize / dist) * uScale;
575
+
576
+ rotated *= scale;
577
+ rotated.x /= uAspect;
578
+
579
+ gl_Position = clipCenter;
580
+ gl_Position.xy += rotated * clipCenter.w;
581
+
582
+ vScreenPos = gl_Position.xy / gl_Position.w;
583
+ }
584
+ `,
585
+ fragmentShader: `
586
+ uniform sampler2D uMap;
587
+ uniform float uOpacity;
588
+ varying vec2 vUv;
589
+ void main() {
590
+ float mask = getMaskAlpha();
591
+ if (mask < 0.01) discard;
592
+ vec4 tex = texture2D(uMap, vUv);
593
+ gl_FragColor = vec4(tex.rgb, tex.a * uOpacity * mask);
594
+ }
595
+ `,
596
+ transparent: true,
597
+ depthWrite: false,
598
+ depthTest: true,
599
+ blending,
600
+ side: THREE5__namespace.DoubleSide
601
+ });
602
+ material.uniforms.uMap.value = this.textureLoader.load(texPath, (tex) => {
603
+ if (c.aspectRatio === void 0 && tex.image.width && tex.image.height) {
604
+ const natAspect = tex.image.width / tex.image.height;
605
+ material.uniforms.uImgAspect.value = natAspect;
606
+ }
607
+ });
608
+ if (c.zBias) {
609
+ material.polygonOffset = true;
610
+ material.polygonOffsetFactor = -c.zBias;
611
+ }
612
+ const mesh = new THREE5__namespace.Mesh(geometry, material);
613
+ mesh.frustumCulled = false;
614
+ mesh.userData = { id: c.id, type: "constellation" };
615
+ mesh.position.copy(center);
616
+ this.root.add(mesh);
617
+ this.items.push({ config: c, mesh, material, baseOpacity: c.opacity });
618
+ });
619
+ }
620
+ update(fov, showArt) {
621
+ this.root.visible = showArt;
622
+ if (!showArt) return;
623
+ for (const item of this.items) {
624
+ const { fade } = item.config;
625
+ let opacity = fade.maxOpacity;
626
+ if (fov >= fade.zoomInStart) {
627
+ opacity = fade.maxOpacity;
628
+ } else if (fov <= fade.zoomInEnd) {
629
+ opacity = fade.minOpacity;
630
+ } else {
631
+ const t = (fade.zoomInStart - fov) / (fade.zoomInStart - fade.zoomInEnd);
632
+ opacity = THREE5__namespace.MathUtils.lerp(fade.maxOpacity, fade.minOpacity, t);
633
+ }
634
+ opacity = Math.min(Math.max(opacity, 0), 1);
635
+ item.material.uniforms.uOpacity.value = opacity;
636
+ }
637
+ }
638
+ setHovered(id) {
639
+ this.hoveredId = id;
640
+ }
641
+ setFocused(id) {
642
+ this.focusedId = id;
643
+ }
644
+ dispose() {
645
+ this.clear();
646
+ this.root.removeFromParent();
647
+ }
648
+ clear() {
649
+ this.items.forEach((i) => {
650
+ this.root.remove(i.mesh);
651
+ i.material.dispose();
652
+ i.mesh.geometry.dispose();
653
+ });
654
+ this.items = [];
655
+ }
408
656
  };
409
657
  }
410
658
  });
@@ -418,17 +666,37 @@ function createEngine({
418
666
  container,
419
667
  onSelect,
420
668
  onHover,
421
- onArrangementChange
669
+ onArrangementChange,
670
+ onFovChange
422
671
  }) {
423
- const renderer = new THREE4__namespace.WebGLRenderer({ antialias: true, alpha: false });
672
+ let hoveredBookId = null;
673
+ let focusedBookId = null;
674
+ let orderRevealEnabled = true;
675
+ let activeBookIndex = -1;
676
+ let orderRevealStrength = 0;
677
+ const hoverCooldowns = /* @__PURE__ */ new Map();
678
+ const COOLDOWN_MS = 2e3;
679
+ const bookIdToIndex = /* @__PURE__ */ new Map();
680
+ const renderer = new THREE5__namespace.WebGLRenderer({ antialias: true, alpha: false });
424
681
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
425
682
  renderer.setSize(container.clientWidth, container.clientHeight);
426
683
  container.appendChild(renderer.domElement);
427
- const scene = new THREE4__namespace.Scene();
428
- scene.background = new THREE4__namespace.Color(0);
429
- const camera = new THREE4__namespace.PerspectiveCamera(60, 1, 0.1, 3e3);
684
+ const scene = new THREE5__namespace.Scene();
685
+ scene.background = new THREE5__namespace.Color(0);
686
+ const camera = new THREE5__namespace.PerspectiveCamera(60, 1, 0.1, 1e4);
430
687
  camera.position.set(0, 0, 0);
431
688
  camera.up.set(0, 1, 0);
689
+ function setHoveredBook(id) {
690
+ if (id === hoveredBookId) return;
691
+ const now = performance.now();
692
+ if (hoveredBookId) {
693
+ hoverCooldowns.set(hoveredBookId, now);
694
+ }
695
+ if (id) {
696
+ hoverCooldowns.get(id) || 0;
697
+ }
698
+ hoveredBookId = id;
699
+ }
432
700
  let running = false;
433
701
  let raf = 0;
434
702
  const state = {
@@ -446,12 +714,15 @@ function createEngine({
446
714
  draggedNodeId: null,
447
715
  draggedStarIndex: -1,
448
716
  draggedDist: 2e3,
449
- draggedGroup: null
717
+ draggedGroup: null,
718
+ tempArrangement: {}
450
719
  };
451
- const mouseNDC = new THREE4__namespace.Vector2();
720
+ const mouseNDC = new THREE5__namespace.Vector2();
452
721
  let isMouseInWindow = false;
453
- let handlers = { onSelect, onHover, onArrangementChange };
722
+ let edgeHoverStart = 0;
723
+ let handlers = { onSelect, onHover, onArrangementChange, onFovChange };
454
724
  let currentConfig;
725
+ const constellationLayer = new ConstellationArtworkLayer(scene);
455
726
  function mix(a, b, t) {
456
727
  return a * (1 - t) + b * t;
457
728
  }
@@ -486,7 +757,7 @@ function createEngine({
486
757
  const phi = Math.atan2(uvY, uvX);
487
758
  const sinTheta = Math.sin(theta);
488
759
  const cosTheta = Math.cos(theta);
489
- return new THREE4__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
760
+ return new THREE5__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
490
761
  }
491
762
  function getMouseWorldVector(pixelX, pixelY, width, height) {
492
763
  const aspect = width / height;
@@ -505,7 +776,7 @@ function createEngine({
505
776
  const phi = Math.atan2(uvY, uvX);
506
777
  const sinTheta = Math.sin(theta);
507
778
  const cosTheta = Math.cos(theta);
508
- const vView = new THREE4__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
779
+ const vView = new THREE5__namespace.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
509
780
  return vView.applyQuaternion(camera.quaternion);
510
781
  }
511
782
  function smartProjectJS(worldPos) {
@@ -518,147 +789,187 @@ function createEngine({
518
789
  const k = mix(kLinear, kStereo, blend);
519
790
  return { x: k * dir.x, y: k * dir.y, z: dir.z };
520
791
  }
521
- const groundGroup = new THREE4__namespace.Group();
792
+ const groundGroup = new THREE5__namespace.Group();
522
793
  scene.add(groundGroup);
523
794
  function createGround() {
524
795
  groundGroup.clear();
525
796
  const radius = 995;
526
- const geometry = new THREE4__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2);
797
+ const geometry = new THREE5__namespace.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2 - 0.15, Math.PI / 2 + 0.15);
527
798
  const material = createSmartMaterial({
528
- uniforms: { color: { value: new THREE4__namespace.Color(526862) } },
529
- 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; }`,
530
- 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); }`,
531
- side: THREE4__namespace.BackSide,
799
+ uniforms: {
800
+ color: { value: new THREE5__namespace.Color(131587) },
801
+ // Very dark almost black
802
+ fogColor: { value: new THREE5__namespace.Color(331812) }
803
+ // Matches atmosphere bot color
804
+ },
805
+ vertexShaderBody: `
806
+ varying vec3 vPos;
807
+ varying vec3 vWorldPos;
808
+ void main() {
809
+ vPos = position;
810
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
811
+ gl_Position = smartProject(mvPosition);
812
+ vScreenPos = gl_Position.xy / gl_Position.w;
813
+ vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
814
+ }
815
+ `,
816
+ fragmentShader: `
817
+ uniform vec3 color;
818
+ uniform vec3 fogColor;
819
+ varying vec3 vPos;
820
+ varying vec3 vWorldPos;
821
+
822
+ void main() {
823
+ float alphaMask = getMaskAlpha();
824
+ if (alphaMask < 0.01) discard;
825
+
826
+ // Procedural Horizon (Mountains)
827
+ float angle = atan(vPos.z, vPos.x);
828
+
829
+ // Simple FBM-like terrain
830
+ float h = 0.0;
831
+ h += sin(angle * 6.0) * 20.0;
832
+ h += sin(angle * 13.0 + 1.0) * 10.0;
833
+ h += sin(angle * 29.0 + 2.0) * 5.0;
834
+ h += sin(angle * 63.0 + 4.0) * 2.0;
835
+
836
+ // Base horizon offset (lift slightly)
837
+ float terrainHeight = h + 10.0;
838
+
839
+ if (vPos.y > terrainHeight) discard;
840
+
841
+ // Atmospheric Haze / Fog on the ground
842
+ // Mix ground color with fog color based on vertical height (fade into horizon)
843
+ // Closer to horizon (higher y) -> more fog
844
+ float fogFactor = smoothstep(-100.0, terrainHeight, vPos.y);
845
+ vec3 finalCol = mix(color, fogColor, fogFactor * 0.5);
846
+
847
+ gl_FragColor = vec4(finalCol, 1.0);
848
+ }
849
+ `,
850
+ side: THREE5__namespace.BackSide,
532
851
  transparent: false,
533
852
  depthWrite: true,
534
853
  depthTest: true
535
854
  });
536
- const ground = new THREE4__namespace.Mesh(geometry, material);
855
+ const ground = new THREE5__namespace.Mesh(geometry, material);
537
856
  groundGroup.add(ground);
538
- const boxGeo = new THREE4__namespace.BoxGeometry(8, 30, 8);
539
- for (let i = 0; i < 12; i++) {
540
- const angle = i / 12 * Math.PI * 2;
541
- const b = new THREE4__namespace.Mesh(boxGeo, material);
542
- const r = radius * 0.98;
543
- b.position.set(Math.cos(angle) * r, -15, Math.sin(angle) * r);
544
- b.lookAt(0, 0, 0);
545
- groundGroup.add(b);
546
- }
547
857
  }
858
+ let atmosphereMesh = null;
548
859
  function createAtmosphere() {
549
- const geometry = new THREE4__namespace.SphereGeometry(990, 128, 64);
860
+ const geometry = new THREE5__namespace.SphereGeometry(990, 64, 64);
550
861
  const material = createSmartMaterial({
551
- uniforms: { top: { value: new THREE4__namespace.Color(0) }, bot: { value: new THREE4__namespace.Color(1712172) } },
552
862
  vertexShaderBody: `
553
-
554
- varying vec3 vP;
555
-
556
- void main() {
557
-
558
- vP = position;
559
-
560
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
561
-
562
- gl_Position = smartProject(mv);
563
-
564
- vScreenPos = gl_Position.xy / gl_Position.w;
565
-
566
- }
567
-
568
- `,
863
+ varying vec3 vWorldNormal;
864
+ void main() {
865
+ vWorldNormal = normalize(position);
866
+ vec4 mv = modelViewMatrix * vec4(position, 1.0);
867
+ gl_Position = smartProject(mv);
868
+ vScreenPos = gl_Position.xy / gl_Position.w;
869
+ }`,
569
870
  fragmentShader: `
570
-
571
- uniform vec3 top;
572
-
573
- uniform vec3 bot;
574
-
575
- varying vec3 vP;
576
-
577
- void main() {
578
-
579
- float alphaMask = getMaskAlpha();
580
-
581
- if (alphaMask < 0.01) discard;
582
-
583
- vec3 n = normalize(vP);
584
-
585
- float h = max(0.0, n.y);
586
-
587
- gl_FragColor = vec4(mix(bot, top, pow(h, 0.6)), 1.0);
588
-
589
- }
590
-
591
- `,
592
- side: THREE4__namespace.BackSide,
871
+ varying vec3 vWorldNormal;
872
+
873
+ uniform float uAtmGlow;
874
+ uniform float uAtmDark;
875
+ uniform vec3 uColorHorizon;
876
+ uniform vec3 uColorZenith;
877
+
878
+ void main() {
879
+ float alphaMask = getMaskAlpha();
880
+ if (alphaMask < 0.01) discard;
881
+
882
+ // Altitude angle (Y is up)
883
+ float h = normalize(vWorldNormal).y;
884
+
885
+ // Gradient Logic
886
+ // 1. Base gradient from Horizon to Zenith
887
+ float t = smoothstep(-0.1, 0.5, h);
888
+
889
+ // Non-linear mix for realistic sky falloff
890
+ // Zenith darkness adjustment
891
+ vec3 skyColor = mix(uColorHorizon * uAtmGlow, uColorZenith * (1.0 - uAtmDark), pow(t, 0.6));
892
+
893
+ // 2. Horizon Glow Band (Simulate scattering/haze layer)
894
+ float horizonBand = exp(-15.0 * abs(h - 0.02)); // Sharp peak near 0
895
+ skyColor += uColorHorizon * horizonBand * 0.5 * uAtmGlow;
896
+
897
+ gl_FragColor = vec4(skyColor, 1.0);
898
+ }
899
+ `,
900
+ side: THREE5__namespace.BackSide,
593
901
  depthWrite: false,
594
902
  depthTest: true
595
903
  });
596
- const atm = new THREE4__namespace.Mesh(geometry, material);
904
+ const atm = new THREE5__namespace.Mesh(geometry, material);
905
+ atmosphereMesh = atm;
597
906
  groundGroup.add(atm);
598
907
  }
599
- const backdropGroup = new THREE4__namespace.Group();
908
+ const backdropGroup = new THREE5__namespace.Group();
600
909
  scene.add(backdropGroup);
601
- function createBackdropStars() {
910
+ function createBackdropStars(count = 31e3) {
602
911
  backdropGroup.clear();
603
- const geometry = new THREE4__namespace.BufferGeometry();
912
+ while (backdropGroup.children.length > 0) {
913
+ const c = backdropGroup.children[0];
914
+ backdropGroup.remove(c);
915
+ if (c.geometry) c.geometry.dispose();
916
+ if (c.material) c.material.dispose();
917
+ }
918
+ const geometry = new THREE5__namespace.BufferGeometry();
604
919
  const positions = [];
605
920
  const sizes = [];
606
921
  const colors = [];
607
- const colorPalette = [
608
- new THREE4__namespace.Color(10203391),
609
- new THREE4__namespace.Color(11190271),
610
- new THREE4__namespace.Color(13293567),
611
- new THREE4__namespace.Color(16316415),
612
- new THREE4__namespace.Color(16774378),
613
- new THREE4__namespace.Color(16765601),
614
- new THREE4__namespace.Color(16764015)
615
- ];
616
922
  const r = 2500;
617
- new THREE4__namespace.Vector3(0, 1, 0.5).normalize();
618
- for (let i = 0; i < 4e3; i++) {
619
- const isMilkyWay = Math.random() < 0.4;
620
- let x, y, z;
621
- if (isMilkyWay) {
622
- const theta = Math.random() * Math.PI * 2;
623
- const scatter = (Math.random() - 0.5) * 0.4;
624
- const v = new THREE4__namespace.Vector3(Math.cos(theta), scatter, Math.sin(theta));
625
- v.normalize();
626
- v.applyAxisAngle(new THREE4__namespace.Vector3(1, 0, 0), THREE4__namespace.MathUtils.degToRad(60));
627
- x = v.x * r;
628
- y = v.y * r;
629
- z = v.z * r;
630
- } else {
631
- const u = Math.random();
632
- const v = Math.random();
633
- const theta = 2 * Math.PI * u;
634
- const phi = Math.acos(2 * v - 1);
635
- x = r * Math.sin(phi) * Math.cos(theta);
636
- y = r * Math.sin(phi) * Math.sin(theta);
637
- z = r * Math.cos(phi);
638
- }
923
+ for (let i = 0; i < count; i++) {
924
+ const u = Math.random();
925
+ const v = Math.random();
926
+ const theta = 2 * Math.PI * u;
927
+ const phi = Math.acos(2 * v - 1);
928
+ const x = r * Math.sin(phi) * Math.cos(theta);
929
+ const y = r * Math.cos(phi);
930
+ const z = r * Math.sin(phi) * Math.sin(theta);
639
931
  positions.push(x, y, z);
640
- const size = 0.5 + -Math.log(Math.random()) * 0.8 * 1.5;
932
+ const size = 1 + -Math.log(Math.random()) * 0.8 * 1.5;
641
933
  sizes.push(size);
642
- const cIndex = Math.floor(Math.random() * colorPalette.length);
643
- const c = colorPalette[cIndex];
644
- colors.push(c.r, c.g, c.b);
934
+ colors.push(1, 1, 1);
645
935
  }
646
- geometry.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(positions, 3));
647
- geometry.setAttribute("size", new THREE4__namespace.Float32BufferAttribute(sizes, 1));
648
- geometry.setAttribute("color", new THREE4__namespace.Float32BufferAttribute(colors, 3));
936
+ geometry.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(positions, 3));
937
+ geometry.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(sizes, 1));
938
+ geometry.setAttribute("color", new THREE5__namespace.Float32BufferAttribute(colors, 3));
649
939
  const material = createSmartMaterial({
650
- uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
940
+ uniforms: {
941
+ pixelRatio: { value: renderer.getPixelRatio() },
942
+ uScale: globalUniforms.uScale
943
+ },
651
944
  vertexShaderBody: `
652
945
  attribute float size;
653
946
  attribute vec3 color;
654
947
  varying vec3 vColor;
655
948
  uniform float pixelRatio;
949
+
950
+ uniform float uAtmExtinction;
951
+
656
952
  void main() {
657
- vColor = color;
953
+ vec3 nPos = normalize(position);
954
+ float altitude = nPos.y;
955
+
956
+ // Simple Extinction & Horizon Fade
957
+ float horizonFade = smoothstep(-0.1, 0.1, altitude);
958
+ float airmass = 1.0 / (max(0.05, altitude + 0.05));
959
+ float extinction = exp(-uAtmExtinction * 0.15 * airmass);
960
+
961
+ // Boost intensity significantly (3.0x)
962
+ vColor = color * 3.0 * extinction * horizonFade;
963
+
658
964
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
659
965
  gl_Position = smartProject(mvPosition);
660
966
  vScreenPos = gl_Position.xy / gl_Position.w;
661
- gl_PointSize = size * pixelRatio * (600.0 / -mvPosition.z);
967
+
968
+ // Non-linear scale with zoom to keep stars looking like points
969
+ // pow(uScale, 0.5) prevents them from getting too large at low FOV
970
+ float zoomScale = pow(uScale, 0.5);
971
+
972
+ gl_PointSize = size * zoomScale * 0.5 * pixelRatio * (800.0 / -mvPosition.z) * horizonFade;
662
973
  }
663
974
  `,
664
975
  fragmentShader: `
@@ -669,31 +980,81 @@ function createEngine({
669
980
  if (dist > 1.0) discard;
670
981
  float alphaMask = getMaskAlpha();
671
982
  if (alphaMask < 0.01) discard;
672
- // Use same Gaussian glow for backdrop
673
- float alpha = exp(-3.0 * dist * dist);
983
+
984
+ // Sharp falloff for intense point look
985
+ float alpha = exp(-4.0 * dist * dist);
674
986
  gl_FragColor = vec4(vColor, alpha * alphaMask);
675
987
  }
676
988
  `,
677
989
  transparent: true,
678
990
  depthWrite: false,
679
- depthTest: true
991
+ depthTest: true,
992
+ blending: THREE5__namespace.AdditiveBlending
680
993
  });
681
- const points = new THREE4__namespace.Points(geometry, material);
994
+ const points = new THREE5__namespace.Points(geometry, material);
682
995
  points.frustumCulled = false;
683
996
  backdropGroup.add(points);
684
997
  }
685
998
  createGround();
686
999
  createAtmosphere();
687
1000
  createBackdropStars();
688
- const raycaster = new THREE4__namespace.Raycaster();
1001
+ const raycaster = new THREE5__namespace.Raycaster();
689
1002
  raycaster.params.Points.threshold = 5;
690
- new THREE4__namespace.Vector2();
691
- const root = new THREE4__namespace.Group();
1003
+ new THREE5__namespace.Vector2();
1004
+ const root = new THREE5__namespace.Group();
692
1005
  scene.add(root);
693
1006
  const nodeById = /* @__PURE__ */ new Map();
694
1007
  const starIndexToId = [];
695
1008
  const dynamicLabels = [];
1009
+ const hoverLabelMat = createSmartMaterial({
1010
+ uniforms: {
1011
+ uMap: { value: null },
1012
+ uSize: { value: new THREE5__namespace.Vector2(1, 1) },
1013
+ uAlpha: { value: 0 },
1014
+ uAngle: { value: 0 }
1015
+ },
1016
+ vertexShaderBody: `
1017
+ uniform vec2 uSize;
1018
+ uniform float uAngle;
1019
+ varying vec2 vUv;
1020
+ void main() {
1021
+ vUv = uv;
1022
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1023
+ vec4 projected = smartProject(mvPos);
1024
+
1025
+ float c = cos(uAngle);
1026
+ float s = sin(uAngle);
1027
+ mat2 rot = mat2(c, -s, s, c);
1028
+ vec2 offset = rot * (position.xy * uSize);
1029
+
1030
+ projected.xy += offset / vec2(uAspect, 1.0);
1031
+ gl_Position = projected;
1032
+ }
1033
+ `,
1034
+ fragmentShader: `
1035
+ uniform sampler2D uMap;
1036
+ uniform float uAlpha;
1037
+ varying vec2 vUv;
1038
+ void main() {
1039
+ float mask = getMaskAlpha();
1040
+ if (mask < 0.01) discard;
1041
+ vec4 tex = texture2D(uMap, vUv);
1042
+ gl_FragColor = vec4(tex.rgb, tex.a * uAlpha * mask);
1043
+ }
1044
+ `,
1045
+ transparent: true,
1046
+ depthWrite: false,
1047
+ depthTest: false
1048
+ // Always on top of stars
1049
+ });
1050
+ const hoverLabelMesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), hoverLabelMat);
1051
+ hoverLabelMesh.visible = false;
1052
+ hoverLabelMesh.renderOrder = 999;
1053
+ hoverLabelMesh.frustumCulled = false;
1054
+ root.add(hoverLabelMesh);
1055
+ let currentHoverNodeId = null;
696
1056
  let constellationLines = null;
1057
+ let boundaryLines = null;
697
1058
  let starPoints = null;
698
1059
  function clearRoot() {
699
1060
  for (const child of [...root.children]) {
@@ -709,6 +1070,7 @@ function createEngine({
709
1070
  starIndexToId.length = 0;
710
1071
  dynamicLabels.length = 0;
711
1072
  constellationLines = null;
1073
+ boundaryLines = null;
712
1074
  starPoints = null;
713
1075
  }
714
1076
  function createTextTexture(text, color = "#ffffff") {
@@ -716,58 +1078,102 @@ function createEngine({
716
1078
  const ctx = canvas.getContext("2d");
717
1079
  if (!ctx) return null;
718
1080
  const fontSize = 96;
719
- ctx.font = `bold ${fontSize}px sans-serif`;
1081
+ const font = `400 ${fontSize}px "Inter", system-ui, sans-serif`;
1082
+ ctx.font = font;
720
1083
  const metrics = ctx.measureText(text);
721
1084
  const w = Math.ceil(metrics.width);
722
1085
  const h = Math.ceil(fontSize * 1.2);
723
1086
  canvas.width = w;
724
1087
  canvas.height = h;
725
- ctx.font = `bold ${fontSize}px sans-serif`;
1088
+ ctx.font = font;
726
1089
  ctx.fillStyle = color;
727
1090
  ctx.textAlign = "center";
728
1091
  ctx.textBaseline = "middle";
729
1092
  ctx.fillText(text, w / 2, h / 2);
730
- const tex = new THREE4__namespace.CanvasTexture(canvas);
731
- tex.minFilter = THREE4__namespace.LinearFilter;
1093
+ const tex = new THREE5__namespace.CanvasTexture(canvas);
1094
+ tex.minFilter = THREE5__namespace.LinearFilter;
732
1095
  return { tex, aspect: w / h };
733
1096
  }
734
1097
  function getPosition(n) {
735
1098
  if (currentConfig?.arrangement) {
736
1099
  const arr = currentConfig.arrangement[n.id];
737
- if (arr) return new THREE4__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
1100
+ if (arr) {
1101
+ if (arr.position[2] === 0) {
1102
+ const x = arr.position[0];
1103
+ const y = arr.position[1];
1104
+ const radius = currentConfig.layout?.radius ?? 2e3;
1105
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
1106
+ const phi = Math.atan2(y, x);
1107
+ const theta = r_norm * (Math.PI / 2);
1108
+ return new THREE5__namespace.Vector3(
1109
+ Math.sin(theta) * Math.cos(phi),
1110
+ Math.cos(theta),
1111
+ Math.sin(theta) * Math.sin(phi)
1112
+ ).multiplyScalar(radius);
1113
+ }
1114
+ return new THREE5__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
1115
+ }
738
1116
  }
739
- return new THREE4__namespace.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
1117
+ return new THREE5__namespace.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
740
1118
  }
741
1119
  function getBoundaryPoint(angle, t, radius) {
742
1120
  const y = 0.05 + t * (1 - 0.05);
743
1121
  const rY = Math.sqrt(1 - y * y);
744
1122
  const x = Math.cos(angle) * rY;
745
1123
  const z = Math.sin(angle) * rY;
746
- return new THREE4__namespace.Vector3(x, y, z).multiplyScalar(radius);
1124
+ return new THREE5__namespace.Vector3(x, y, z).multiplyScalar(radius);
747
1125
  }
748
1126
  function buildFromModel(model, cfg) {
749
1127
  clearRoot();
750
- scene.background = cfg.background && cfg.background !== "transparent" ? new THREE4__namespace.Color(cfg.background) : new THREE4__namespace.Color(0);
1128
+ bookIdToIndex.clear();
1129
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE5__namespace.Color(cfg.background) : new THREE5__namespace.Color(0);
751
1130
  const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
752
1131
  const laidOut = computeLayoutPositions(model, layoutCfg);
1132
+ const divisionPositions = /* @__PURE__ */ new Map();
1133
+ if (cfg.arrangement) {
1134
+ const divMap = /* @__PURE__ */ new Map();
1135
+ for (const n of laidOut.nodes) {
1136
+ if (n.level === 2 && n.parent) {
1137
+ const list = divMap.get(n.parent) ?? [];
1138
+ list.push(n);
1139
+ divMap.set(n.parent, list);
1140
+ }
1141
+ }
1142
+ for (const [divId, books] of divMap.entries()) {
1143
+ const centroid = new THREE5__namespace.Vector3();
1144
+ let count = 0;
1145
+ for (const b of books) {
1146
+ const p = getPosition(b);
1147
+ centroid.add(p);
1148
+ count++;
1149
+ }
1150
+ if (count > 0) {
1151
+ centroid.divideScalar(count);
1152
+ divisionPositions.set(divId, centroid);
1153
+ }
1154
+ }
1155
+ }
753
1156
  const starPositions = [];
754
1157
  const starSizes = [];
755
1158
  const starColors = [];
1159
+ const starPhases = [];
1160
+ const starBookIndices = [];
1161
+ const starChapterIndices = [];
756
1162
  const SPECTRAL_COLORS = [
757
- new THREE4__namespace.Color(10203391),
758
- // O - Blue
759
- new THREE4__namespace.Color(11190271),
760
- // B - Blue-white
761
- new THREE4__namespace.Color(13293567),
762
- // A - White-blue
763
- new THREE4__namespace.Color(16316415),
1163
+ new THREE5__namespace.Color(14544639),
1164
+ // O - Blueish White
1165
+ new THREE5__namespace.Color(15660287),
1166
+ // B - White
1167
+ new THREE5__namespace.Color(16317695),
1168
+ // A - White
1169
+ new THREE5__namespace.Color(16777208),
764
1170
  // F - White
765
- new THREE4__namespace.Color(16774378),
766
- // G - Yellow-white
767
- new THREE4__namespace.Color(16765601),
768
- // K - Yellow-orange
769
- new THREE4__namespace.Color(16764015)
770
- // M - Orange-red
1171
+ new THREE5__namespace.Color(16775406),
1172
+ // G - Yellowish White
1173
+ new THREE5__namespace.Color(16773085),
1174
+ // K - Pale Orange
1175
+ new THREE5__namespace.Color(16771788)
1176
+ // M - Light Orange
771
1177
  ];
772
1178
  let minWeight = Infinity;
773
1179
  let maxWeight = -Infinity;
@@ -792,32 +1198,62 @@ function createEngine({
792
1198
  let baseSize = 3.5;
793
1199
  if (typeof n.weight === "number") {
794
1200
  const t = (n.weight - minWeight) / (maxWeight - minWeight);
795
- baseSize = 3 + t * 4;
1201
+ baseSize = 0.1 + Math.pow(t, 0.5) * 11.9;
796
1202
  }
797
1203
  starSizes.push(baseSize);
798
1204
  const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
799
1205
  const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
800
1206
  starColors.push(c.r, c.g, c.b);
801
- } else if (n.level === 2) {
802
- const color = "#ffffff";
803
- const texRes = createTextTexture(n.label, color);
1207
+ starPhases.push(Math.random() * Math.PI * 2);
1208
+ let bIdx = -1;
1209
+ if (n.parent) {
1210
+ if (!bookIdToIndex.has(n.parent)) {
1211
+ bookIdToIndex.set(n.parent, bookIdToIndex.size + 1);
1212
+ }
1213
+ bIdx = bookIdToIndex.get(n.parent);
1214
+ }
1215
+ starBookIndices.push(bIdx);
1216
+ let cIdx = 0;
1217
+ if (n.meta?.chapter) cIdx = Number(n.meta.chapter);
1218
+ starChapterIndices.push(cIdx);
1219
+ }
1220
+ if (n.level === 1 || n.level === 2 || n.level === 3) {
1221
+ let color = "#ffffff";
1222
+ if (n.level === 1) color = "#38bdf8";
1223
+ else if (n.level === 2) color = "#cbd5e1";
1224
+ else if (n.level === 3) color = "#94a3b8";
1225
+ let labelText = n.label;
1226
+ if (n.level === 3 && n.meta?.chapter) {
1227
+ labelText = String(n.meta.chapter);
1228
+ }
1229
+ const texRes = createTextTexture(labelText, color);
804
1230
  if (texRes) {
805
- const baseScale = 0.05;
806
- const size = new THREE4__namespace.Vector2(baseScale * texRes.aspect, baseScale);
1231
+ let baseScale = 0.05;
1232
+ if (n.level === 1) baseScale = 0.08;
1233
+ else if (n.level === 2) baseScale = 0.04;
1234
+ else if (n.level === 3) baseScale = 0.03;
1235
+ const size = new THREE5__namespace.Vector2(baseScale * texRes.aspect, baseScale);
807
1236
  const mat = createSmartMaterial({
808
1237
  uniforms: {
809
1238
  uMap: { value: texRes.tex },
810
1239
  uSize: { value: size },
811
- uAlpha: { value: 0 }
1240
+ uAlpha: { value: 0 },
1241
+ uAngle: { value: 0 }
812
1242
  },
813
1243
  vertexShaderBody: `
814
1244
  uniform vec2 uSize;
1245
+ uniform float uAngle;
815
1246
  varying vec2 vUv;
816
1247
  void main() {
817
1248
  vUv = uv;
818
1249
  vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
819
1250
  vec4 projected = smartProject(mvPos);
820
- vec2 offset = position.xy * uSize;
1251
+
1252
+ float c = cos(uAngle);
1253
+ float s = sin(uAngle);
1254
+ mat2 rot = mat2(c, -s, s, c);
1255
+ vec2 offset = rot * (position.xy * uSize);
1256
+
821
1257
  projected.xy += offset / vec2(uAspect, 1.0);
822
1258
  gl_Position = projected;
823
1259
  }
@@ -837,8 +1273,19 @@ function createEngine({
837
1273
  depthWrite: false,
838
1274
  depthTest: true
839
1275
  });
840
- const mesh = new THREE4__namespace.Mesh(new THREE4__namespace.PlaneGeometry(1, 1), mat);
841
- const p = getPosition(n);
1276
+ const mesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), mat);
1277
+ let p = getPosition(n);
1278
+ if (n.level === 1) {
1279
+ if (divisionPositions.has(n.id)) {
1280
+ p.copy(divisionPositions.get(n.id));
1281
+ }
1282
+ const r = layoutCfg.radius * 0.95;
1283
+ const angle = Math.atan2(p.z, p.x);
1284
+ p.set(r * Math.cos(angle), 150, r * Math.sin(angle));
1285
+ } else if (n.level === 3) {
1286
+ p.y += 30;
1287
+ p.multiplyScalar(1.001);
1288
+ }
842
1289
  mesh.position.set(p.x, p.y, p.z);
843
1290
  mesh.scale.set(size.x, size.y, 1);
844
1291
  mesh.frustumCulled = false;
@@ -848,47 +1295,119 @@ function createEngine({
848
1295
  }
849
1296
  }
850
1297
  }
851
- const starGeo = new THREE4__namespace.BufferGeometry();
852
- starGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(starPositions, 3));
853
- starGeo.setAttribute("size", new THREE4__namespace.Float32BufferAttribute(starSizes, 1));
854
- starGeo.setAttribute("color", new THREE4__namespace.Float32BufferAttribute(starColors, 3));
1298
+ const starGeo = new THREE5__namespace.BufferGeometry();
1299
+ starGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(starPositions, 3));
1300
+ starGeo.setAttribute("size", new THREE5__namespace.Float32BufferAttribute(starSizes, 1));
1301
+ starGeo.setAttribute("color", new THREE5__namespace.Float32BufferAttribute(starColors, 3));
1302
+ starGeo.setAttribute("phase", new THREE5__namespace.Float32BufferAttribute(starPhases, 1));
1303
+ starGeo.setAttribute("bookIndex", new THREE5__namespace.Float32BufferAttribute(starBookIndices, 1));
1304
+ starGeo.setAttribute("chapterIndex", new THREE5__namespace.Float32BufferAttribute(starChapterIndices, 1));
855
1305
  const starMat = createSmartMaterial({
856
- uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
1306
+ uniforms: {
1307
+ pixelRatio: { value: renderer.getPixelRatio() },
1308
+ uScale: globalUniforms.uScale,
1309
+ uTime: globalUniforms.uTime,
1310
+ uActiveBookIndex: { value: -1 },
1311
+ uOrderRevealStrength: { value: 0 },
1312
+ uGlobalDimFactor: { value: ORDER_REVEAL_CONFIG.globalDim },
1313
+ uPulseParams: { value: new THREE5__namespace.Vector3(
1314
+ ORDER_REVEAL_CONFIG.pulseDuration,
1315
+ ORDER_REVEAL_CONFIG.delayPerChapter,
1316
+ ORDER_REVEAL_CONFIG.pulseAmplitude
1317
+ ) }
1318
+ },
857
1319
  vertexShaderBody: `
858
1320
  attribute float size;
859
1321
  attribute vec3 color;
1322
+ attribute float phase;
1323
+ attribute float bookIndex;
1324
+ attribute float chapterIndex;
1325
+
860
1326
  varying vec3 vColor;
861
1327
  uniform float pixelRatio;
1328
+
1329
+ uniform float uTime;
1330
+ uniform float uAtmExtinction;
1331
+ uniform float uAtmTwinkle;
1332
+
1333
+ uniform float uActiveBookIndex;
1334
+ uniform float uOrderRevealStrength;
1335
+ uniform float uGlobalDimFactor;
1336
+ uniform vec3 uPulseParams;
1337
+
862
1338
  void main() {
863
- vColor = color;
1339
+ vec3 nPos = normalize(position);
1340
+
1341
+ // 1. Altitude (Y is UP)
1342
+ float altitude = nPos.y;
1343
+
1344
+ // 2. Atmospheric Extinction (Airmass approximation)
1345
+ float airmass = 1.0 / (max(0.02, altitude + 0.05));
1346
+ float extinction = exp(-uAtmExtinction * 0.1 * airmass);
1347
+
1348
+ // Fade out stars below horizon
1349
+ float horizonFade = smoothstep(-0.1, 0.05, altitude);
1350
+
1351
+ // 3. Scintillation
1352
+ float turbulence = 1.0 + (1.0 - smoothstep(0.0, 1.0, altitude)) * 2.0;
1353
+ float twinkle = sin(uTime * 3.0 + phase + position.x * 0.01) * 0.5 + 0.5;
1354
+ float scintillation = mix(1.0, twinkle * 2.0, uAtmTwinkle * 0.5 * turbulence);
1355
+
1356
+ // --- Order Reveal Logic ---
1357
+ float isTarget = 1.0 - min(1.0, abs(bookIndex - uActiveBookIndex));
1358
+
1359
+ // Dimming
1360
+ float dimFactor = mix(1.0, uGlobalDimFactor, uOrderRevealStrength * (1.0 - isTarget));
1361
+
1362
+ // Pulse
1363
+ float delay = chapterIndex * uPulseParams.y;
1364
+ float cycleDuration = uPulseParams.x * 2.5;
1365
+ float t = mod(uTime - delay, cycleDuration);
1366
+
1367
+ float pulse = smoothstep(0.0, 0.2, t) * (1.0 - smoothstep(0.4, uPulseParams.x, t));
1368
+ pulse = max(0.0, pulse);
1369
+
1370
+ float activePulse = pulse * uPulseParams.z * isTarget * uOrderRevealStrength;
1371
+
1372
+ vec3 baseColor = color * extinction * horizonFade * scintillation;
1373
+ vColor = baseColor * dimFactor;
1374
+ vColor += vec3(1.0, 0.8, 0.4) * activePulse;
1375
+
864
1376
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
865
1377
  gl_Position = smartProject(mvPosition);
866
1378
  vScreenPos = gl_Position.xy / gl_Position.w;
867
- gl_PointSize = size * pixelRatio * (2000.0 / -mvPosition.z);
1379
+
1380
+ float sizeBoost = 1.0 + activePulse * 0.8;
1381
+ gl_PointSize = (size * sizeBoost * 1.5) * uScale * pixelRatio * (2000.0 / -mvPosition.z) * horizonFade;
868
1382
  }
869
1383
  `,
870
1384
  fragmentShader: `
871
1385
  varying vec3 vColor;
872
1386
  void main() {
873
1387
  vec2 coord = gl_PointCoord - vec2(0.5);
874
- // Use larger drawing area for glow
875
- float dist = length(coord) * 2.0;
876
- if (dist > 1.0) discard;
1388
+ float d = length(coord) * 2.0;
1389
+ if (d > 1.0) discard;
877
1390
 
878
1391
  float alphaMask = getMaskAlpha();
879
1392
  if (alphaMask < 0.01) discard;
880
1393
 
881
- // Gaussian Glow: Sharp core, soft halo
882
- float alpha = exp(-3.0 * dist * dist);
1394
+ float dd = d * d;
1395
+ // Stellarium Profile
1396
+ float core = exp(-20.0 * dd);
1397
+ float halo = exp(-4.0 * dd);
883
1398
 
884
- gl_FragColor = vec4(vColor, alpha * alphaMask);
1399
+ vec3 cCore = vec3(1.0) * core * 1.5;
1400
+ vec3 cHalo = vColor * halo * 0.6;
1401
+
1402
+ gl_FragColor = vec4((cCore + cHalo) * alphaMask, 1.0);
885
1403
  }
886
1404
  `,
887
1405
  transparent: true,
888
1406
  depthWrite: false,
889
- depthTest: true
1407
+ depthTest: true,
1408
+ blending: THREE5__namespace.AdditiveBlending
890
1409
  });
891
- starPoints = new THREE4__namespace.Points(starGeo, starMat);
1410
+ starPoints = new THREE5__namespace.Points(starGeo, starMat);
892
1411
  starPoints.frustumCulled = false;
893
1412
  root.add(starPoints);
894
1413
  const linePoints = [];
@@ -914,31 +1433,119 @@ function createEngine({
914
1433
  }
915
1434
  }
916
1435
  if (linePoints.length > 0) {
917
- const lineGeo = new THREE4__namespace.BufferGeometry();
918
- lineGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(linePoints, 3));
1436
+ const lineGeo = new THREE5__namespace.BufferGeometry();
1437
+ lineGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(linePoints, 3));
919
1438
  const lineMat = createSmartMaterial({
920
- uniforms: { color: { value: new THREE4__namespace.Color(4478310) } },
1439
+ uniforms: { color: { value: new THREE5__namespace.Color(11193599) } },
921
1440
  vertexShaderBody: `uniform vec3 color; varying vec3 vColor; void main() { vColor = color; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); gl_Position = smartProject(mvPosition); vScreenPos = gl_Position.xy / gl_Position.w; }`,
922
- fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
1441
+ fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.4 * alphaMask); }`,
923
1442
  transparent: true,
924
1443
  depthWrite: false,
925
- blending: THREE4__namespace.AdditiveBlending
1444
+ blending: THREE5__namespace.AdditiveBlending
926
1445
  });
927
- constellationLines = new THREE4__namespace.LineSegments(lineGeo, lineMat);
1446
+ constellationLines = new THREE5__namespace.LineSegments(lineGeo, lineMat);
928
1447
  constellationLines.frustumCulled = false;
929
1448
  root.add(constellationLines);
930
1449
  }
1450
+ if (cfg.groups) {
1451
+ for (const [bookId, chapters] of bookMap.entries()) {
1452
+ const bookNode = nodeById.get(bookId);
1453
+ if (!bookNode) continue;
1454
+ const bookName = bookNode.meta?.book || bookNode.label;
1455
+ const groupList = cfg.groups[bookName.toLowerCase()];
1456
+ if (groupList) {
1457
+ groupList.forEach((g, idx) => {
1458
+ const groupId = `G:${bookId}:${idx}`;
1459
+ let p = new THREE5__namespace.Vector3();
1460
+ if (cfg.arrangement && cfg.arrangement[groupId]) {
1461
+ const arr = cfg.arrangement[groupId];
1462
+ p.set(arr.position[0], arr.position[1], arr.position[2]);
1463
+ } else {
1464
+ const relevantChapters = chapters.filter((c) => {
1465
+ const ch = c.meta?.chapter;
1466
+ return ch >= g.start && ch <= g.end;
1467
+ });
1468
+ if (relevantChapters.length === 0) return;
1469
+ for (const c of relevantChapters) {
1470
+ p.add(getPosition(c));
1471
+ }
1472
+ p.divideScalar(relevantChapters.length);
1473
+ }
1474
+ const labelText = `${g.name} (${g.start}-${g.end})`;
1475
+ const texRes = createTextTexture(labelText, "#4fa4fa80");
1476
+ if (texRes) {
1477
+ const baseScale = 0.036;
1478
+ const size = new THREE5__namespace.Vector2(baseScale * texRes.aspect, baseScale);
1479
+ const mat = createSmartMaterial({
1480
+ uniforms: {
1481
+ uMap: { value: texRes.tex },
1482
+ uSize: { value: size },
1483
+ uAlpha: { value: 0 },
1484
+ uAngle: { value: 0 }
1485
+ },
1486
+ vertexShaderBody: `
1487
+ uniform vec2 uSize;
1488
+ uniform float uAngle;
1489
+ varying vec2 vUv;
1490
+ void main() {
1491
+ vUv = uv;
1492
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
1493
+ vec4 projected = smartProject(mvPos);
1494
+
1495
+ float c = cos(uAngle);
1496
+ float s = sin(uAngle);
1497
+ mat2 rot = mat2(c, -s, s, c);
1498
+ vec2 offset = rot * (position.xy * uSize);
1499
+
1500
+ projected.xy += offset / vec2(uAspect, 1.0);
1501
+ gl_Position = projected;
1502
+ }
1503
+ `,
1504
+ fragmentShader: `
1505
+ uniform sampler2D uMap;
1506
+ uniform float uAlpha;
1507
+ varying vec2 vUv;
1508
+ void main() {
1509
+ float mask = getMaskAlpha();
1510
+ if (mask < 0.01) discard;
1511
+ vec4 tex = texture2D(uMap, vUv);
1512
+ gl_FragColor = vec4(tex.rgb, tex.a * uAlpha * mask);
1513
+ }
1514
+ `,
1515
+ transparent: true,
1516
+ depthWrite: false,
1517
+ depthTest: true
1518
+ });
1519
+ const mesh = new THREE5__namespace.Mesh(new THREE5__namespace.PlaneGeometry(1, 1), mat);
1520
+ mesh.position.copy(p);
1521
+ mesh.scale.set(size.x, size.y, 1);
1522
+ mesh.frustumCulled = false;
1523
+ mesh.userData = { id: groupId };
1524
+ root.add(mesh);
1525
+ const node = {
1526
+ id: groupId,
1527
+ label: labelText,
1528
+ level: 2.5,
1529
+ // Special Level
1530
+ parent: bookId
1531
+ };
1532
+ dynamicLabels.push({ obj: mesh, node, initialScale: size.clone() });
1533
+ }
1534
+ });
1535
+ }
1536
+ }
1537
+ }
931
1538
  const boundaries = laidOut.meta?.divisionBoundaries ?? [];
932
1539
  if (boundaries.length > 0) {
933
1540
  const boundaryMat = createSmartMaterial({
934
- uniforms: { color: { value: new THREE4__namespace.Color(5601177) } },
1541
+ uniforms: { color: { value: new THREE5__namespace.Color(5601177) } },
935
1542
  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; }`,
936
1543
  fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
937
1544
  transparent: true,
938
1545
  depthWrite: false,
939
- blending: THREE4__namespace.AdditiveBlending
1546
+ blending: THREE5__namespace.AdditiveBlending
940
1547
  });
941
- const boundaryGeo = new THREE4__namespace.BufferGeometry();
1548
+ const boundaryGeo = new THREE5__namespace.BufferGeometry();
942
1549
  const bPoints = [];
943
1550
  boundaries.forEach((angle) => {
944
1551
  const steps = 32;
@@ -951,18 +1558,80 @@ function createEngine({
951
1558
  bPoints.push(p2.x, p2.y, p2.z);
952
1559
  }
953
1560
  });
954
- boundaryGeo.setAttribute("position", new THREE4__namespace.Float32BufferAttribute(bPoints, 3));
955
- const boundaryLines = new THREE4__namespace.LineSegments(boundaryGeo, boundaryMat);
1561
+ boundaryGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(bPoints, 3));
1562
+ boundaryLines = new THREE5__namespace.LineSegments(boundaryGeo, boundaryMat);
956
1563
  boundaryLines.frustumCulled = false;
957
1564
  root.add(boundaryLines);
958
1565
  }
1566
+ if (cfg.polygons) {
1567
+ const polyPoints = [];
1568
+ const rBase = layoutCfg.radius;
1569
+ for (const pts of Object.values(cfg.polygons)) {
1570
+ if (pts.length < 2) continue;
1571
+ for (let i = 0; i < pts.length; i++) {
1572
+ const p1_2d = pts[i];
1573
+ const p2_2d = pts[(i + 1) % pts.length];
1574
+ if (!p1_2d || !p2_2d) continue;
1575
+ const project2dTo3d = (p) => {
1576
+ const x = p[0];
1577
+ const y = p[1];
1578
+ const r_norm = Math.sqrt(x * x + y * y);
1579
+ const phi = Math.atan2(y, x);
1580
+ const theta = r_norm * (Math.PI / 2);
1581
+ return new THREE5__namespace.Vector3(
1582
+ Math.sin(theta) * Math.cos(phi),
1583
+ Math.cos(theta),
1584
+ Math.sin(theta) * Math.sin(phi)
1585
+ ).multiplyScalar(rBase);
1586
+ };
1587
+ const v1 = project2dTo3d(p1_2d);
1588
+ const v2 = project2dTo3d(p2_2d);
1589
+ polyPoints.push(v1.x, v1.y, v1.z);
1590
+ polyPoints.push(v2.x, v2.y, v2.z);
1591
+ }
1592
+ }
1593
+ if (polyPoints.length > 0) {
1594
+ const polyGeo = new THREE5__namespace.BufferGeometry();
1595
+ polyGeo.setAttribute("position", new THREE5__namespace.Float32BufferAttribute(polyPoints, 3));
1596
+ const polyMat = createSmartMaterial({
1597
+ uniforms: { color: { value: new THREE5__namespace.Color(3718648) } },
1598
+ // Cyan-ish
1599
+ 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; }`,
1600
+ fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.2 * alphaMask); }`,
1601
+ transparent: true,
1602
+ depthWrite: false,
1603
+ blending: THREE5__namespace.AdditiveBlending
1604
+ });
1605
+ const polyLines = new THREE5__namespace.LineSegments(polyGeo, polyMat);
1606
+ polyLines.frustumCulled = false;
1607
+ root.add(polyLines);
1608
+ }
1609
+ }
959
1610
  resize();
960
1611
  }
961
1612
  let lastData = void 0;
962
1613
  let lastAdapter = void 0;
963
1614
  let lastModel = void 0;
1615
+ let lastAppliedLon = void 0;
1616
+ let lastAppliedLat = void 0;
1617
+ let lastBackdropCount = void 0;
964
1618
  function setConfig(cfg) {
965
1619
  currentConfig = cfg;
1620
+ if (typeof cfg.camera?.lon === "number" && cfg.camera.lon !== lastAppliedLon) {
1621
+ state.lon = cfg.camera.lon;
1622
+ state.targetLon = cfg.camera.lon;
1623
+ lastAppliedLon = cfg.camera.lon;
1624
+ }
1625
+ if (typeof cfg.camera?.lat === "number" && cfg.camera.lat !== lastAppliedLat) {
1626
+ state.lat = cfg.camera.lat;
1627
+ state.targetLat = cfg.camera.lat;
1628
+ lastAppliedLat = cfg.camera.lat;
1629
+ }
1630
+ const desiredBackdropCount = typeof cfg.backdropStarsCount === "number" ? cfg.backdropStarsCount : 4e3;
1631
+ if (lastBackdropCount !== desiredBackdropCount) {
1632
+ createBackdropStars(desiredBackdropCount);
1633
+ lastBackdropCount = desiredBackdropCount;
1634
+ }
966
1635
  let shouldRebuild = false;
967
1636
  let model = cfg.model;
968
1637
  if (!model && cfg.data && cfg.adapter) {
@@ -986,6 +1655,29 @@ function createEngine({
986
1655
  } else if (cfg.arrangement && starPoints) {
987
1656
  if (lastModel) buildFromModel(lastModel, cfg);
988
1657
  }
1658
+ if (cfg.constellations) {
1659
+ constellationLayer.load(cfg.constellations, (id) => {
1660
+ if (cfg.arrangement && cfg.arrangement[id]) {
1661
+ const arr = cfg.arrangement[id];
1662
+ if (arr.position[2] === 0) {
1663
+ const x = arr.position[0];
1664
+ const y = arr.position[1];
1665
+ const radius = cfg.layout?.radius ?? 2e3;
1666
+ const r_norm = Math.min(1, Math.sqrt(x * x + y * y) / radius);
1667
+ const phi = Math.atan2(y, x);
1668
+ const theta = r_norm * (Math.PI / 2);
1669
+ return new THREE5__namespace.Vector3(
1670
+ Math.sin(theta) * Math.cos(phi),
1671
+ Math.cos(theta),
1672
+ Math.sin(theta) * Math.sin(phi)
1673
+ ).multiplyScalar(radius);
1674
+ }
1675
+ return new THREE5__namespace.Vector3(arr.position[0], arr.position[1], arr.position[2]);
1676
+ }
1677
+ const n = nodeById.get(id);
1678
+ return n ? getPosition(n) : null;
1679
+ });
1680
+ }
989
1681
  }
990
1682
  function setHandlers(next) {
991
1683
  handlers = next;
@@ -993,22 +1685,25 @@ function createEngine({
993
1685
  function getFullArrangement() {
994
1686
  const arr = {};
995
1687
  if (starPoints && starPoints.geometry.attributes.position) {
996
- const positions = starPoints.geometry.attributes.position.array;
1688
+ const attr = starPoints.geometry.attributes.position;
997
1689
  for (let i = 0; i < starIndexToId.length; i++) {
998
1690
  const id = starIndexToId[i];
999
1691
  if (id) {
1000
- const x = positions[i * 3] ?? 0;
1001
- const y = positions[i * 3 + 1] ?? 0;
1002
- const z = positions[i * 3 + 2] ?? 0;
1003
- if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {
1004
- arr[id] = { position: [x, y, z] };
1005
- }
1692
+ const x = attr.getX(i);
1693
+ const y = attr.getY(i);
1694
+ const z = attr.getZ(i);
1695
+ arr[id] = { position: [x, y, z] };
1006
1696
  }
1007
1697
  }
1008
1698
  }
1009
1699
  for (const item of dynamicLabels) {
1700
+ if (item.node.level === 3) continue;
1010
1701
  arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
1011
1702
  }
1703
+ for (const item of constellationLayer.getItems()) {
1704
+ arr[item.config.id] = { position: [item.mesh.position.x, item.mesh.position.y, item.mesh.position.z] };
1705
+ }
1706
+ Object.assign(arr, state.tempArrangement);
1012
1707
  return arr;
1013
1708
  }
1014
1709
  function pick(ev) {
@@ -1017,16 +1712,18 @@ function createEngine({
1017
1712
  const mY = ev.clientY - rect.top;
1018
1713
  mouseNDC.x = mX / rect.width * 2 - 1;
1019
1714
  mouseNDC.y = -(mY / rect.height) * 2 + 1;
1020
- let closestLabel = null;
1021
- let minLabelDist = 40;
1022
1715
  const uScale = globalUniforms.uScale.value;
1023
1716
  const uAspect = camera.aspect;
1024
1717
  const w = rect.width;
1025
1718
  const h = rect.height;
1719
+ let closestLabel = null;
1720
+ let minLabelDist = 40;
1026
1721
  for (const item of dynamicLabels) {
1027
1722
  if (!item.obj.visible) continue;
1028
1723
  const pWorld = item.obj.position;
1029
1724
  const pProj = smartProjectJS(pWorld);
1725
+ const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1726
+ if (isBehind) continue;
1030
1727
  const xNDC = pProj.x * uScale / uAspect;
1031
1728
  const yNDC = pProj.y * uScale;
1032
1729
  const sX = (xNDC * 0.5 + 0.5) * w;
@@ -1034,24 +1731,72 @@ function createEngine({
1034
1731
  const dx = mX - sX;
1035
1732
  const dy = mY - sY;
1036
1733
  const d = Math.sqrt(dx * dx + dy * dy);
1037
- const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1038
- if (!isBehind && d < minLabelDist) {
1734
+ if (d < minLabelDist) {
1039
1735
  minLabelDist = d;
1040
1736
  closestLabel = item;
1041
1737
  }
1042
1738
  }
1043
- if (closestLabel) return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
1044
- const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
1045
- raycaster.ray.origin.set(0, 0, 0);
1046
- raycaster.ray.direction.copy(worldDir);
1047
- raycaster.params.Points.threshold = 5 * (state.fov / 60);
1048
- const hits = raycaster.intersectObject(starPoints, false);
1049
- const pointHit = hits[0];
1050
- if (pointHit && pointHit.index !== void 0) {
1051
- const id = starIndexToId[pointHit.index];
1052
- if (id) {
1053
- const node = nodeById.get(id);
1054
- if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
1739
+ if (closestLabel) {
1740
+ return { type: "label", node: closestLabel.node, object: closestLabel.obj, point: closestLabel.obj.position.clone(), index: void 0 };
1741
+ }
1742
+ let closestConst = null;
1743
+ let minConstDist = Infinity;
1744
+ for (const item of constellationLayer.getItems()) {
1745
+ if (!item.mesh.visible) continue;
1746
+ const pWorld = item.mesh.position;
1747
+ const pProj = smartProjectJS(pWorld);
1748
+ const isBehind = globalUniforms.uBlend.value > 0.5 && pProj.z > 0.4 || globalUniforms.uBlend.value < 0.1 && pProj.z > -0.1;
1749
+ if (isBehind) continue;
1750
+ const uniforms = item.material.uniforms;
1751
+ if (!uniforms || !uniforms.uSize) continue;
1752
+ const uSize = uniforms.uSize.value;
1753
+ const uImgAspect = uniforms.uImgAspect.value;
1754
+ const uImgRotation = uniforms.uImgRotation.value;
1755
+ const dist = pWorld.length();
1756
+ if (dist < 1e-3) continue;
1757
+ const scale = uSize / dist * uScale;
1758
+ const halfH_px = scale / 2 * (h / 2);
1759
+ const halfW_px = halfH_px * uImgAspect;
1760
+ const xNDC = pProj.x * uScale / uAspect;
1761
+ const yNDC = pProj.y * uScale;
1762
+ const sX = (xNDC * 0.5 + 0.5) * w;
1763
+ const sY = (-yNDC * 0.5 + 0.5) * h;
1764
+ const dx = mX - sX;
1765
+ const dy = mY - sY;
1766
+ const dy_cart = -dy;
1767
+ const cr = Math.cos(-uImgRotation);
1768
+ const sr = Math.sin(-uImgRotation);
1769
+ const localX = dx * cr - dy_cart * sr;
1770
+ const localY = dx * sr + dy_cart * cr;
1771
+ if (Math.abs(localX) < halfW_px * 1.2 && Math.abs(localY) < halfH_px * 1.2) {
1772
+ const d = Math.sqrt(dx * dx + dy * dy);
1773
+ if (!closestConst || d < minConstDist) {
1774
+ minConstDist = d;
1775
+ closestConst = item;
1776
+ }
1777
+ }
1778
+ }
1779
+ if (closestConst) {
1780
+ const fakeNode = {
1781
+ id: closestConst.config.id,
1782
+ label: closestConst.config.title,
1783
+ level: -1
1784
+ };
1785
+ return { type: "constellation", node: fakeNode, object: closestConst.mesh, point: closestConst.mesh.position.clone(), index: void 0 };
1786
+ }
1787
+ if (starPoints) {
1788
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
1789
+ raycaster.ray.origin.set(0, 0, 0);
1790
+ raycaster.ray.direction.copy(worldDir);
1791
+ raycaster.params.Points.threshold = 5 * (state.fov / 60);
1792
+ const hits = raycaster.intersectObject(starPoints, false);
1793
+ const pointHit = hits[0];
1794
+ if (pointHit && pointHit.index !== void 0) {
1795
+ const id = starIndexToId[pointHit.index];
1796
+ if (id) {
1797
+ const node = nodeById.get(id);
1798
+ if (node) return { type: "star", node, index: pointHit.index, point: pointHit.point, object: void 0 };
1799
+ }
1055
1800
  }
1056
1801
  }
1057
1802
  return void 0;
@@ -1079,21 +1824,25 @@ function createEngine({
1079
1824
  if (starId) {
1080
1825
  const starNode = nodeById.get(starId);
1081
1826
  if (starNode && starNode.parent === bookId) {
1082
- children.push({ index: i, initialPos: new THREE4__namespace.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
1827
+ children.push({ index: i, initialPos: new THREE5__namespace.Vector3(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]) });
1083
1828
  }
1084
1829
  }
1085
1830
  }
1086
1831
  }
1087
1832
  state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
1088
1833
  state.draggedStarIndex = -1;
1834
+ } else if (hit.type === "constellation") {
1835
+ state.draggedGroup = null;
1836
+ state.draggedStarIndex = -1;
1089
1837
  }
1090
- return;
1091
1838
  }
1839
+ return;
1092
1840
  }
1093
1841
  state.dragMode = "camera";
1094
1842
  state.isDragging = true;
1095
1843
  state.velocityX = 0;
1096
1844
  state.velocityY = 0;
1845
+ state.tempArrangement = {};
1097
1846
  document.body.style.cursor = "grabbing";
1098
1847
  }
1099
1848
  function onMouseMove(e) {
@@ -1114,16 +1863,29 @@ function createEngine({
1114
1863
  } else if (state.draggedGroup && state.draggedNodeId) {
1115
1864
  const group = state.draggedGroup;
1116
1865
  const item = dynamicLabels.find((l) => l.node.id === state.draggedNodeId);
1117
- if (item) item.obj.position.copy(newPos);
1866
+ if (item) {
1867
+ item.obj.position.copy(newPos);
1868
+ state.tempArrangement[item.node.id] = { position: [newPos.x, newPos.y, newPos.z] };
1869
+ } else if (state.draggedNodeId) {
1870
+ const cItem = constellationLayer.getItems().find((c) => c.config.id === state.draggedNodeId);
1871
+ if (cItem) {
1872
+ cItem.mesh.position.copy(newPos);
1873
+ state.tempArrangement[state.draggedNodeId] = { position: [newPos.x, newPos.y, newPos.z] };
1874
+ }
1875
+ }
1118
1876
  const vStart = group.labelInitialPos.clone().normalize();
1119
1877
  const vEnd = newPos.clone().normalize();
1120
- const q = new THREE4__namespace.Quaternion().setFromUnitVectors(vStart, vEnd);
1878
+ const q = new THREE5__namespace.Quaternion().setFromUnitVectors(vStart, vEnd);
1121
1879
  if (starPoints && group.children.length > 0) {
1122
1880
  const attr = starPoints.geometry.attributes.position;
1123
- const tempVec = new THREE4__namespace.Vector3();
1881
+ const tempVec = new THREE5__namespace.Vector3();
1124
1882
  for (const child of group.children) {
1125
1883
  tempVec.copy(child.initialPos).applyQuaternion(q);
1126
1884
  attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
1885
+ const id = starIndexToId[child.index];
1886
+ if (id) {
1887
+ state.tempArrangement[id] = { position: [tempVec.x, tempVec.y, tempVec.z] };
1888
+ }
1127
1889
  }
1128
1890
  attr.needsUpdate = true;
1129
1891
  }
@@ -1143,9 +1905,30 @@ function createEngine({
1143
1905
  state.lat = state.targetLat;
1144
1906
  } else {
1145
1907
  const hit = pick(e);
1908
+ if (hit && hit.type === "star") {
1909
+ if (currentHoverNodeId !== hit.node.id) {
1910
+ currentHoverNodeId = hit.node.id;
1911
+ const res = createTextTexture(hit.node.label, "#ffd700");
1912
+ if (res) {
1913
+ hoverLabelMat.uniforms.uMap.value = res.tex;
1914
+ const baseScale = 0.03;
1915
+ const size = new THREE5__namespace.Vector2(baseScale * res.aspect, baseScale);
1916
+ hoverLabelMat.uniforms.uSize.value = size;
1917
+ hoverLabelMesh.scale.set(size.x, size.y, 1);
1918
+ }
1919
+ }
1920
+ hoverLabelMesh.position.copy(hit.point);
1921
+ hoverLabelMat.uniforms.uAlpha.value = 1;
1922
+ hoverLabelMesh.visible = true;
1923
+ } else {
1924
+ currentHoverNodeId = null;
1925
+ hoverLabelMat.uniforms.uAlpha.value = 0;
1926
+ hoverLabelMesh.visible = false;
1927
+ }
1146
1928
  if (hit?.node.id !== handlers._lastHoverId) {
1147
1929
  handlers._lastHoverId = hit?.node.id;
1148
1930
  handlers.onHover?.(hit?.node);
1931
+ constellationLayer.setHovered(hit?.node.id ?? null);
1149
1932
  }
1150
1933
  document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
1151
1934
  }
@@ -1165,7 +1948,14 @@ function createEngine({
1165
1948
  document.body.style.cursor = "default";
1166
1949
  } else {
1167
1950
  const hit = pick(e);
1168
- if (hit) handlers.onSelect?.(hit.node);
1951
+ if (hit) {
1952
+ handlers.onSelect?.(hit.node);
1953
+ constellationLayer.setFocused(hit.node.id);
1954
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
1955
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
1956
+ } else {
1957
+ setFocusedBook(null);
1958
+ }
1169
1959
  }
1170
1960
  }
1171
1961
  function onWheel(e) {
@@ -1176,25 +1966,26 @@ function createEngine({
1176
1966
  const zoomSpeed = 1e-3 * state.fov;
1177
1967
  state.fov += e.deltaY * zoomSpeed;
1178
1968
  state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
1969
+ handlers.onFovChange?.(state.fov);
1179
1970
  updateUniforms();
1180
1971
  const vAfter = getMouseViewVector(state.fov, aspect);
1181
- const quaternion = new THREE4__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
1972
+ const quaternion = new THREE5__namespace.Quaternion().setFromUnitVectors(vAfter, vBefore);
1182
1973
  const y = Math.sin(state.lat);
1183
1974
  const r = Math.cos(state.lat);
1184
1975
  const x = r * Math.sin(state.lon);
1185
1976
  const z = -r * Math.cos(state.lon);
1186
- const currentLook = new THREE4__namespace.Vector3(x, y, z);
1977
+ const currentLook = new THREE5__namespace.Vector3(x, y, z);
1187
1978
  const camForward = currentLook.clone().normalize();
1188
1979
  const camUp = camera.up.clone();
1189
- const camRight = new THREE4__namespace.Vector3().crossVectors(camForward, camUp).normalize();
1190
- const camUpOrtho = new THREE4__namespace.Vector3().crossVectors(camRight, camForward).normalize();
1191
- const mat = new THREE4__namespace.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
1192
- const qOld = new THREE4__namespace.Quaternion().setFromRotationMatrix(mat);
1980
+ const camRight = new THREE5__namespace.Vector3().crossVectors(camForward, camUp).normalize();
1981
+ const camUpOrtho = new THREE5__namespace.Vector3().crossVectors(camRight, camForward).normalize();
1982
+ const mat = new THREE5__namespace.Matrix4().makeBasis(camRight, camUpOrtho, camForward.clone().negate());
1983
+ const qOld = new THREE5__namespace.Quaternion().setFromRotationMatrix(mat);
1193
1984
  const qNew = qOld.clone().multiply(quaternion);
1194
- const newForward = new THREE4__namespace.Vector3(0, 0, -1).applyQuaternion(qNew);
1985
+ const newForward = new THREE5__namespace.Vector3(0, 0, -1).applyQuaternion(qNew);
1195
1986
  state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
1196
1987
  state.lon = Math.atan2(newForward.x, -newForward.z);
1197
- const newUp = new THREE4__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
1988
+ const newUp = new THREE5__namespace.Vector3(0, 1, 0).applyQuaternion(qNew);
1198
1989
  camera.up.copy(newUp);
1199
1990
  if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
1200
1991
  const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
@@ -1235,79 +2026,205 @@ function createEngine({
1235
2026
  function tick() {
1236
2027
  if (!running) return;
1237
2028
  raf = requestAnimationFrame(tick);
1238
- if (!state.isDragging && isMouseInWindow) {
2029
+ const now = performance.now();
2030
+ globalUniforms.uTime.value = now / 1e3;
2031
+ let activeId = null;
2032
+ if (focusedBookId) {
2033
+ activeId = focusedBookId;
2034
+ } else if (hoveredBookId) {
2035
+ const lastExit = hoverCooldowns.get(hoveredBookId) || 0;
2036
+ if (now - lastExit > COOLDOWN_MS) {
2037
+ activeId = hoveredBookId;
2038
+ }
2039
+ }
2040
+ const targetStrength = orderRevealEnabled && activeId ? 1 : 0;
2041
+ orderRevealStrength = mix(orderRevealStrength, targetStrength, 0.1);
2042
+ if (orderRevealStrength > 1e-3 || targetStrength > 0) {
2043
+ if (activeId && bookIdToIndex.has(activeId)) {
2044
+ activeBookIndex = bookIdToIndex.get(activeId);
2045
+ }
2046
+ if (starPoints && starPoints.material) {
2047
+ const m = starPoints.material;
2048
+ if (m.uniforms.uActiveBookIndex) m.uniforms.uActiveBookIndex.value = activeBookIndex;
2049
+ if (m.uniforms.uOrderRevealStrength) m.uniforms.uOrderRevealStrength.value = orderRevealStrength;
2050
+ }
2051
+ }
2052
+ let panX = 0;
2053
+ let panY = 0;
2054
+ if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
1239
2055
  const t = ENGINE_CONFIG.edgePanThreshold;
1240
- const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
1241
- let panX = 0;
1242
- let panY = 0;
1243
- if (mouseNDC.x < -1 + t) {
1244
- const s = (-1 + t - mouseNDC.x) / t;
1245
- panX = -s * s * speedBase;
1246
- } else if (mouseNDC.x > 1 - t) {
1247
- const s = (mouseNDC.x - (1 - t)) / t;
1248
- panX = s * s * speedBase;
1249
- }
1250
- if (mouseNDC.y < -1 + t) {
1251
- const s = (-1 + t - mouseNDC.y) / t;
1252
- panY = -s * s * speedBase;
1253
- } else if (mouseNDC.y > 1 - t) {
1254
- const s = (mouseNDC.y - (1 - t)) / t;
1255
- panY = s * s * speedBase;
1256
- }
1257
- if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
1258
- state.lon += panX;
1259
- state.lat += panY;
1260
- state.targetLon = state.lon;
1261
- state.targetLat = state.lat;
2056
+ const inZoneX = mouseNDC.x < -1 + t || mouseNDC.x > 1 - t;
2057
+ const inZoneY = mouseNDC.y < -1 + t || mouseNDC.y > 1 - t;
2058
+ if (inZoneX || inZoneY) {
2059
+ if (edgeHoverStart === 0) edgeHoverStart = performance.now();
2060
+ if (performance.now() - edgeHoverStart > ENGINE_CONFIG.edgePanDelay) {
2061
+ const speedBase = ENGINE_CONFIG.edgePanMaxSpeed * (state.fov / ENGINE_CONFIG.defaultFov);
2062
+ if (mouseNDC.x < -1 + t) {
2063
+ const s = (-1 + t - mouseNDC.x) / t;
2064
+ panX = -s * s * speedBase;
2065
+ } else if (mouseNDC.x > 1 - t) {
2066
+ const s = (mouseNDC.x - (1 - t)) / t;
2067
+ panX = s * s * speedBase;
2068
+ }
2069
+ if (mouseNDC.y < -1 + t) {
2070
+ const s = (-1 + t - mouseNDC.y) / t;
2071
+ panY = -s * s * speedBase;
2072
+ } else if (mouseNDC.y > 1 - t) {
2073
+ const s = (mouseNDC.y - (1 - t)) / t;
2074
+ panY = s * s * speedBase;
2075
+ }
2076
+ }
1262
2077
  } else {
1263
- state.lon += state.velocityX;
1264
- state.lat += state.velocityY;
1265
- state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1266
- state.velocityY *= ENGINE_CONFIG.inertiaDamping;
1267
- if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
1268
- if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
2078
+ edgeHoverStart = 0;
1269
2079
  }
2080
+ } else {
2081
+ edgeHoverStart = 0;
2082
+ }
2083
+ if (Math.abs(panX) > 0 || Math.abs(panY) > 0) {
2084
+ state.lon += panX;
2085
+ state.lat += panY;
2086
+ state.targetLon = state.lon;
2087
+ state.targetLat = state.lat;
1270
2088
  } else if (!state.isDragging) {
1271
2089
  state.lon += state.velocityX;
1272
2090
  state.lat += state.velocityY;
1273
2091
  state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1274
2092
  state.velocityY *= ENGINE_CONFIG.inertiaDamping;
2093
+ if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2094
+ if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
1275
2095
  }
1276
2096
  state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
1277
2097
  const y = Math.sin(state.lat);
1278
2098
  const r = Math.cos(state.lat);
1279
2099
  const x = r * Math.sin(state.lon);
1280
2100
  const z = -r * Math.cos(state.lon);
1281
- const target = new THREE4__namespace.Vector3(x, y, z);
1282
- const idealUp = new THREE4__namespace.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
2101
+ const target = new THREE5__namespace.Vector3(x, y, z);
2102
+ const idealUp = new THREE5__namespace.Vector3(-Math.sin(state.lat) * Math.sin(state.lon), Math.cos(state.lat), Math.sin(state.lat) * Math.cos(state.lon)).normalize();
1283
2103
  camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
1284
2104
  camera.up.normalize();
1285
2105
  camera.lookAt(target);
2106
+ camera.updateMatrixWorld();
2107
+ camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
1286
2108
  updateUniforms();
1287
- const cameraDir = new THREE4__namespace.Vector3();
1288
- camera.getWorldDirection(cameraDir);
1289
- const objPos = new THREE4__namespace.Vector3();
1290
- const objDir = new THREE4__namespace.Vector3();
1291
- const SHOW_LABELS_FOV = 60;
2109
+ constellationLayer.update(state.fov, currentConfig?.showConstellationArt ?? false);
2110
+ backdropGroup.visible = currentConfig?.showBackdropStars ?? true;
2111
+ if (atmosphereMesh) atmosphereMesh.visible = currentConfig?.showAtmosphere ?? false;
2112
+ const DIVISION_THRESHOLD = 60;
2113
+ const showDivisions = state.fov > DIVISION_THRESHOLD;
2114
+ if (constellationLines) {
2115
+ constellationLines.visible = currentConfig?.showConstellationLines ?? false;
2116
+ }
2117
+ if (boundaryLines) {
2118
+ boundaryLines.visible = currentConfig?.showDivisionBoundaries ?? false;
2119
+ }
2120
+ const rect = renderer.domElement.getBoundingClientRect();
2121
+ const screenW = rect.width;
2122
+ const screenH = rect.height;
2123
+ const aspect = screenW / screenH;
2124
+ const labelsToCheck = [];
2125
+ const occupied = [];
2126
+ function isOverlapping(x2, y2, w, h) {
2127
+ for (const r2 of occupied) {
2128
+ if (x2 < r2.x + r2.w && x2 + w > r2.x && y2 < r2.y + r2.h && y2 + h > r2.y) return true;
2129
+ }
2130
+ return false;
2131
+ }
2132
+ const showBookLabels = currentConfig?.showBookLabels === true;
2133
+ const showDivisionLabels = currentConfig?.showDivisionLabels === true;
2134
+ const showChapterLabels = currentConfig?.showChapterLabels === true;
2135
+ const showGroupLabels = currentConfig?.showGroupLabels === true;
2136
+ const showBooks = state.fov < 120;
2137
+ const showChapters = state.fov < 70;
1292
2138
  for (const item of dynamicLabels) {
1293
2139
  const uniforms = item.obj.material.uniforms;
1294
- let targetAlpha = 0;
1295
- if (state.fov < SHOW_LABELS_FOV) {
1296
- item.obj.getWorldPosition(objPos);
1297
- objDir.subVectors(objPos, camera.position).normalize();
1298
- const dot = cameraDir.dot(objDir);
1299
- const fullVisibleDot = 0.98;
1300
- const invisibleDot = 0.9;
1301
- let gazeOpacity = 0;
1302
- if (dot >= fullVisibleDot) gazeOpacity = 1;
1303
- else if (dot > invisibleDot) gazeOpacity = (dot - invisibleDot) / (fullVisibleDot - invisibleDot);
1304
- const zoomFactor = 1 - THREE4__namespace.MathUtils.smoothstep(40, SHOW_LABELS_FOV, state.fov);
1305
- targetAlpha = gazeOpacity * zoomFactor;
1306
- }
1307
- if (uniforms.uAlpha) {
1308
- uniforms.uAlpha.value = THREE4__namespace.MathUtils.lerp(uniforms.uAlpha.value, targetAlpha, 0.1);
2140
+ const level = item.node.level;
2141
+ let isEnabled = false;
2142
+ if (level === 2 && showBookLabels) isEnabled = true;
2143
+ else if (level === 1 && showDivisionLabels) isEnabled = true;
2144
+ else if (level === 3 && showChapterLabels) isEnabled = true;
2145
+ else if (level === 2.5 && showGroupLabels) isEnabled = true;
2146
+ if (!isEnabled) {
2147
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
1309
2148
  item.obj.visible = uniforms.uAlpha.value > 0.01;
2149
+ continue;
1310
2150
  }
2151
+ const pWorld = item.obj.position;
2152
+ const pProj = smartProjectJS(pWorld);
2153
+ if (pProj.z > 0.2) {
2154
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2155
+ item.obj.visible = uniforms.uAlpha.value > 0.01;
2156
+ continue;
2157
+ }
2158
+ if (level === 2 && !showBooks && item.node.id !== state.draggedNodeId) {
2159
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2160
+ item.obj.visible = uniforms.uAlpha.value > 0.01;
2161
+ continue;
2162
+ }
2163
+ if ((level === 3 || level === 2.5) && !showChapters && item.node.id !== state.draggedNodeId) {
2164
+ uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(uniforms.uAlpha.value, 0, 0.2);
2165
+ item.obj.visible = uniforms.uAlpha.value > 0.01;
2166
+ continue;
2167
+ }
2168
+ const ndcX = pProj.x * globalUniforms.uScale.value / aspect;
2169
+ const ndcY = pProj.y * globalUniforms.uScale.value;
2170
+ const sX = (ndcX * 0.5 + 0.5) * screenW;
2171
+ const sY = (-ndcY * 0.5 + 0.5) * screenH;
2172
+ const size = uniforms.uSize.value;
2173
+ const pixelH = size.y * screenH * 0.8;
2174
+ const pixelW = size.x * screenH * 0.8;
2175
+ labelsToCheck.push({ item, sX, sY, w: pixelW, h: pixelH, uniforms, level, ndcX, ndcY });
2176
+ }
2177
+ const hoverId = handlers._lastHoverId;
2178
+ const selectedId = state.draggedNodeId;
2179
+ labelsToCheck.sort((a, b) => {
2180
+ const getScore = (l) => {
2181
+ if (l.item.node.id === selectedId) return 10;
2182
+ if (l.item.node.id === hoverId) return 9;
2183
+ const level = l.level;
2184
+ if (level === 2) return 5;
2185
+ if (level === 1) return showDivisions ? 6 : 1;
2186
+ return 0;
2187
+ };
2188
+ return getScore(b) - getScore(a);
2189
+ });
2190
+ for (const l of labelsToCheck) {
2191
+ let target2 = 0;
2192
+ const isSpecial = l.item.node.id === selectedId || l.item.node.id === hoverId;
2193
+ if (l.level === 1) {
2194
+ let rot = 0;
2195
+ const blend = globalUniforms.uBlend.value;
2196
+ if (blend > 0.5) {
2197
+ const dx = l.sX - screenW / 2;
2198
+ const dy = l.sY - screenH / 2;
2199
+ rot = Math.atan2(-dy, -dx) - Math.PI / 2;
2200
+ }
2201
+ l.uniforms.uAngle.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAngle.value, rot, 0.1);
2202
+ }
2203
+ if (l.level === 2) {
2204
+ if (showBooks || isSpecial) {
2205
+ target2 = 1;
2206
+ occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2207
+ }
2208
+ } else if (l.level === 1) {
2209
+ if (showDivisions || isSpecial) {
2210
+ const pad = -5;
2211
+ if (!isOverlapping(l.sX - l.w / 2 - pad, l.sY - l.h / 2 - pad, l.w + pad * 2, l.h + pad * 2)) {
2212
+ target2 = 1;
2213
+ occupied.push({ x: l.sX - l.w / 2, y: l.sY - l.h / 2, w: l.w, h: l.h });
2214
+ }
2215
+ }
2216
+ } else if (l.level === 2.5 || l.level === 3) {
2217
+ if (showChapters || isSpecial) {
2218
+ target2 = 1;
2219
+ if (!isSpecial) {
2220
+ const dist = Math.sqrt(l.ndcX * l.ndcX + l.ndcY * l.ndcY);
2221
+ const focusFade = 1 - THREE5__namespace.MathUtils.smoothstep(0.4, 0.7, dist);
2222
+ target2 *= focusFade;
2223
+ }
2224
+ }
2225
+ }
2226
+ l.uniforms.uAlpha.value = THREE5__namespace.MathUtils.lerp(l.uniforms.uAlpha.value, target2, 0.1);
2227
+ l.item.obj.visible = l.uniforms.uAlpha.value > 0.01;
1311
2228
  }
1312
2229
  renderer.render(scene, camera);
1313
2230
  }
@@ -1323,16 +2240,31 @@ function createEngine({
1323
2240
  }
1324
2241
  function dispose() {
1325
2242
  stop();
2243
+ constellationLayer.dispose();
1326
2244
  renderer.dispose();
1327
2245
  renderer.domElement.remove();
1328
2246
  }
1329
- return { setConfig, start, stop, dispose, setHandlers, getFullArrangement };
2247
+ function setHoveredBook(id) {
2248
+ if (id === hoveredBookId) return;
2249
+ if (hoveredBookId) {
2250
+ hoverCooldowns.set(hoveredBookId, performance.now());
2251
+ }
2252
+ hoveredBookId = id;
2253
+ }
2254
+ function setFocusedBook(id) {
2255
+ focusedBookId = id;
2256
+ }
2257
+ function setOrderRevealEnabled(enabled) {
2258
+ orderRevealEnabled = enabled;
2259
+ }
2260
+ return { setConfig, start, stop, dispose, setHandlers, getFullArrangement, setHoveredBook, setFocusedBook, setOrderRevealEnabled };
1330
2261
  }
1331
- var ENGINE_CONFIG;
2262
+ var ENGINE_CONFIG, ORDER_REVEAL_CONFIG;
1332
2263
  var init_createEngine = __esm({
1333
2264
  "src/engine/createEngine.ts"() {
1334
2265
  init_layout();
1335
2266
  init_materials();
2267
+ init_ConstellationArtworkLayer();
1336
2268
  ENGINE_CONFIG = {
1337
2269
  minFov: 10,
1338
2270
  maxFov: 165,
@@ -1345,16 +2277,26 @@ var init_createEngine = __esm({
1345
2277
  zenithStrength: 0.02,
1346
2278
  horizonLockStrength: 0.05,
1347
2279
  edgePanThreshold: 0.15,
1348
- edgePanMaxSpeed: 0.02
2280
+ edgePanMaxSpeed: 0.02,
2281
+ edgePanDelay: 250
2282
+ };
2283
+ ORDER_REVEAL_CONFIG = {
2284
+ globalDim: 0.85,
2285
+ pulseAmplitude: 0.6,
2286
+ pulseDuration: 2,
2287
+ delayPerChapter: 0.1
1349
2288
  };
1350
2289
  }
1351
2290
  });
1352
2291
  var StarMap = react.forwardRef(
1353
- ({ config, className, onSelect, onHover, onArrangementChange }, ref) => {
2292
+ ({ config, className, onSelect, onHover, onArrangementChange, onFovChange }, ref) => {
1354
2293
  const containerRef = react.useRef(null);
1355
2294
  const engineRef = react.useRef(null);
1356
2295
  react.useImperativeHandle(ref, () => ({
1357
- getFullArrangement: () => engineRef.current?.getFullArrangement?.()
2296
+ getFullArrangement: () => engineRef.current?.getFullArrangement?.(),
2297
+ setHoveredBook: (id) => engineRef.current?.setHoveredBook?.(id),
2298
+ setFocusedBook: (id) => engineRef.current?.setFocusedBook?.(id),
2299
+ setOrderRevealEnabled: (enabled) => engineRef.current?.setOrderRevealEnabled?.(enabled)
1358
2300
  }));
1359
2301
  react.useEffect(() => {
1360
2302
  let disposed = false;
@@ -1366,7 +2308,8 @@ var StarMap = react.forwardRef(
1366
2308
  container: containerRef.current,
1367
2309
  onSelect,
1368
2310
  onHover,
1369
- onArrangementChange
2311
+ onArrangementChange,
2312
+ onFovChange
1370
2313
  });
1371
2314
  engineRef.current.setConfig(config);
1372
2315
  engineRef.current.start();
@@ -1382,8 +2325,8 @@ var StarMap = react.forwardRef(
1382
2325
  engineRef.current?.setConfig?.(config);
1383
2326
  }, [config]);
1384
2327
  react.useEffect(() => {
1385
- engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange });
1386
- }, [onSelect, onHover, onArrangementChange]);
2328
+ engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange, onFovChange });
2329
+ }, [onSelect, onHover, onArrangementChange, onFovChange]);
1387
2330
  return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: containerRef, className, style: { width: "100%", height: "100%" } });
1388
2331
  }
1389
2332
  );
@@ -1412,10 +2355,11 @@ function bibleToSceneModel(data) {
1412
2355
  });
1413
2356
  links.push({ source: did, target: tid });
1414
2357
  for (const b of d.books) {
2358
+ const bookLabel = b.name;
1415
2359
  const bid = id.book(b.key);
1416
2360
  nodes.push({
1417
2361
  id: bid,
1418
- label: b.name,
2362
+ label: bookLabel,
1419
2363
  level: 2,
1420
2364
  parent: did,
1421
2365
  meta: { testament: t.name, division: d.name, bookKey: b.key, book: b.name }
@@ -1427,7 +2371,7 @@ function bibleToSceneModel(data) {
1427
2371
  const cid = id.chapter(b.key, chapterNum);
1428
2372
  nodes.push({
1429
2373
  id: cid,
1430
- label: `${b.name} ${chapterNum}`,
2374
+ label: `${bookLabel} ${chapterNum}`,
1431
2375
  level: 3,
1432
2376
  parent: bid,
1433
2377
  weight: verseCounts[i],
@@ -30490,9 +31434,149 @@ var default_stars_default = {
30490
31434
  }
30491
31435
  ]
30492
31436
  };
31437
+ var defaultGenerateOptions = {
31438
+ seed: 12345,
31439
+ discRadius: 2e3,
31440
+ milkyWayEnabled: true,
31441
+ milkyWayAngle: 60,
31442
+ milkyWayWidth: 0.3,
31443
+ // Width in dot-product space
31444
+ milkyWayStrength: 0.7,
31445
+ noiseScale: 2,
31446
+ noiseStrength: 0.4,
31447
+ clusterSpread: 0.08
31448
+ // Radians approx
31449
+ };
31450
+ var RNG = class {
31451
+ seed;
31452
+ constructor(seed) {
31453
+ this.seed = seed;
31454
+ }
31455
+ // Returns 0..1
31456
+ next() {
31457
+ this.seed = (this.seed * 9301 + 49297) % 233280;
31458
+ return this.seed / 233280;
31459
+ }
31460
+ // Returns range [min, max)
31461
+ range(min, max) {
31462
+ return min + this.next() * (max - min);
31463
+ }
31464
+ // Uniform random on upper hemisphere (y > 0)
31465
+ randomOnSphere() {
31466
+ const y = this.next();
31467
+ const theta = 2 * Math.PI * this.next();
31468
+ const r = Math.sqrt(1 - y * y);
31469
+ const x = r * Math.cos(theta);
31470
+ const z = r * Math.sin(theta);
31471
+ return new THREE5__namespace.Vector3(x, y, z);
31472
+ }
31473
+ };
31474
+ function simpleNoise3D(v, scale) {
31475
+ const s = scale;
31476
+ return (Math.sin(v.x * s) + Math.sin(v.y * s * 1.3) + Math.sin(v.z * s * 1.7) + Math.sin(v.x * s * 2.1 + v.y * s * 2.1) * 0.5) / 3.5;
31477
+ }
31478
+ function getDensity(v, opts, mwNormal) {
31479
+ let density = 0.3;
31480
+ if (opts.milkyWayEnabled) {
31481
+ const dot = v.dot(mwNormal);
31482
+ const dist = Math.abs(dot);
31483
+ const band = Math.exp(-(dist * dist) / (opts.milkyWayWidth * opts.milkyWayWidth));
31484
+ density += band * opts.milkyWayStrength;
31485
+ }
31486
+ const noise = simpleNoise3D(v, opts.noiseScale);
31487
+ density *= 1 + noise * opts.noiseStrength;
31488
+ return Math.max(0.01, density);
31489
+ }
31490
+ function generateArrangement(bible, options = {}) {
31491
+ const opts = { ...defaultGenerateOptions, ...options };
31492
+ const rng = new RNG(opts.seed);
31493
+ const arrangement = {};
31494
+ const books = [];
31495
+ bible.testaments.forEach((t) => {
31496
+ t.divisions.forEach((d) => {
31497
+ d.books.forEach((b) => {
31498
+ books.push({
31499
+ key: b.key,
31500
+ name: b.name,
31501
+ chapters: b.chapters,
31502
+ division: d.name,
31503
+ testament: t.name
31504
+ });
31505
+ });
31506
+ });
31507
+ });
31508
+ const bookCount = books.length;
31509
+ const mwRad = THREE5__namespace.MathUtils.degToRad(opts.milkyWayAngle);
31510
+ const mwNormal = new THREE5__namespace.Vector3(Math.sin(mwRad), Math.cos(mwRad), 0).normalize();
31511
+ const anchors = [];
31512
+ for (let i = 0; i < bookCount; i++) {
31513
+ let bestP = new THREE5__namespace.Vector3();
31514
+ let valid = false;
31515
+ let attempt = 0;
31516
+ while (!valid && attempt < 100) {
31517
+ const p = rng.randomOnSphere();
31518
+ const d = getDensity(p, opts, mwNormal);
31519
+ if (rng.next() < d) {
31520
+ bestP = p;
31521
+ valid = true;
31522
+ }
31523
+ attempt++;
31524
+ }
31525
+ if (!valid) bestP = rng.randomOnSphere();
31526
+ anchors.push(bestP);
31527
+ }
31528
+ anchors.sort((a, b) => {
31529
+ const lonA = Math.atan2(a.z, a.x);
31530
+ const lonB = Math.atan2(b.z, b.x);
31531
+ return lonA - lonB;
31532
+ });
31533
+ books.forEach((book, i) => {
31534
+ const anchor = anchors[i];
31535
+ const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
31536
+ arrangement[`B:${book.key}`] = { position: [anchorPos.x, anchorPos.y, anchorPos.z] };
31537
+ for (let c = 0; c < book.chapters; c++) {
31538
+ const localSpread = opts.clusterSpread * (0.8 + rng.next() * 0.4);
31539
+ const offset = new THREE5__namespace.Vector3(
31540
+ (rng.next() - 0.5) * 2,
31541
+ (rng.next() - 0.5) * 2,
31542
+ (rng.next() - 0.5) * 2
31543
+ ).normalize().multiplyScalar(rng.next() * localSpread);
31544
+ const starDir = anchor.clone().add(offset).normalize();
31545
+ if (starDir.y < 0.01) {
31546
+ starDir.y = 0.01;
31547
+ starDir.normalize();
31548
+ }
31549
+ const starPos = starDir.multiplyScalar(opts.discRadius);
31550
+ const chapId = `C:${book.key}:${c + 1}`;
31551
+ arrangement[chapId] = { position: [starPos.x, starPos.y, starPos.z] };
31552
+ }
31553
+ });
31554
+ const divisions = /* @__PURE__ */ new Map();
31555
+ books.forEach((book, i) => {
31556
+ const anchor = anchors[i];
31557
+ const anchorPos = anchor.clone().multiplyScalar(opts.discRadius);
31558
+ const divId = `D:${book.testament}:${book.division}`;
31559
+ if (!divisions.has(divId)) {
31560
+ divisions.set(divId, { sum: new THREE5__namespace.Vector3(), count: 0 });
31561
+ }
31562
+ const entry = divisions.get(divId);
31563
+ entry.sum.add(anchorPos);
31564
+ entry.count++;
31565
+ });
31566
+ divisions.forEach((val, key) => {
31567
+ if (val.count > 0) {
31568
+ val.sum.divideScalar(val.count);
31569
+ val.sum.normalize().multiplyScalar(opts.discRadius * 0.9);
31570
+ arrangement[key] = { position: [val.sum.x, val.sum.y, val.sum.z] };
31571
+ }
31572
+ });
31573
+ return arrangement;
31574
+ }
30493
31575
 
30494
31576
  exports.StarMap = StarMap;
30495
31577
  exports.bibleToSceneModel = bibleToSceneModel;
31578
+ exports.defaultGenerateOptions = defaultGenerateOptions;
30496
31579
  exports.defaultStars = default_stars_default;
31580
+ exports.generateArrangement = generateArrangement;
30497
31581
  //# sourceMappingURL=index.cjs.map
30498
31582
  //# sourceMappingURL=index.cjs.map