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