@project-skymap/library 0.2.0 → 0.3.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,6 +1,4 @@
1
1
  import * as THREE4 from 'three';
2
- import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3
- import { DragControls } from 'three/examples/jsm/controls/DragControls.js';
4
2
  import { forwardRef, useRef, useImperativeHandle, useEffect } from 'react';
5
3
  import { jsx } from 'react/jsx-runtime';
6
4
 
@@ -312,100 +310,80 @@ var init_layout = __esm({
312
310
  init_constellations();
313
311
  }
314
312
  });
315
- function matches(node, when) {
316
- for (const [k, v] of Object.entries(when)) {
317
- const val = node[k] !== void 0 ? node[k] : node.meta?.[k];
318
- if (val !== v) return false;
319
- }
320
- return true;
321
- }
322
- function getColorRules(cfg) {
323
- const anyCfg = cfg;
324
- const styleColors = anyCfg?.style?.colors;
325
- if (Array.isArray(styleColors)) return styleColors;
326
- const visualsColorBy = anyCfg?.visuals?.colorBy;
327
- if (Array.isArray(visualsColorBy)) {
328
- return visualsColorBy.map((r) => ({
329
- when: r.when ?? {},
330
- color: r.value,
331
- opacity: r.opacity
332
- }));
333
- }
334
- return [];
313
+
314
+ // src/engine/shaders.ts
315
+ var BLEND_CHUNK, MASK_CHUNK;
316
+ var init_shaders = __esm({
317
+ "src/engine/shaders.ts"() {
318
+ BLEND_CHUNK = `
319
+ uniform float uScale;
320
+ uniform float uAspect;
321
+ uniform float uBlend;
322
+
323
+ vec4 smartProject(vec4 viewPos) {
324
+ vec3 dir = normalize(viewPos.xyz);
325
+ float dist = length(viewPos.xyz);
326
+ float zLinear = max(0.01, -dir.z);
327
+ float kStereo = 2.0 / (1.0 - dir.z);
328
+ float kLinear = 1.0 / zLinear;
329
+ float k = mix(kLinear, kStereo, uBlend);
330
+ vec2 projected = vec2(k * dir.x, k * dir.y);
331
+ projected *= uScale;
332
+ projected.x /= uAspect;
333
+ float zMetric = -1.0 + (dist / 2000.0);
334
+ // Clip backward facing points in fisheye mode
335
+ if (uBlend > 0.5 && dir.z > 0.4) return vec4(10.0, 10.0, 10.0, 1.0);
336
+ // Clip very close points in linear mode
337
+ if (uBlend < 0.1 && dir.z > -0.1) return vec4(10.0, 10.0, 10.0, 1.0);
338
+ return vec4(projected, zMetric, 1.0);
335
339
  }
336
- function getSizeRules(cfg) {
337
- const anyCfg = cfg;
338
- const styleSizes = anyCfg?.style?.sizes;
339
- if (Array.isArray(styleSizes)) return styleSizes;
340
- const visualsSizeBy = anyCfg?.visuals?.sizeBy;
341
- if (Array.isArray(visualsSizeBy)) return visualsSizeBy;
342
- return [];
340
+ `;
341
+ MASK_CHUNK = `
342
+ uniform float uAspect;
343
+ uniform float uBlend;
344
+ varying vec2 vScreenPos;
345
+ float getMaskAlpha() {
346
+ if (uBlend < 0.1) return 1.0;
347
+ vec2 p = vScreenPos;
348
+ p.x *= uAspect;
349
+ float dist = length(p);
350
+ float t = smoothstep(0.75, 1.0, uBlend);
351
+ float currentRadius = mix(2.5, 1.0, t);
352
+ float edgeSoftness = mix(0.5, 0.02, t);
353
+ return 1.0 - smoothstep(currentRadius - edgeSoftness, currentRadius, dist);
343
354
  }
344
- function applyVisuals({
345
- model,
346
- cfg,
347
- meshById
348
- }) {
349
- const colorRules = getColorRules(cfg);
350
- const sizeRules = getSizeRules(cfg);
351
- const domains = {};
352
- for (const rule of sizeRules) {
353
- const key = JSON.stringify(rule.when) + "|" + rule.field;
354
- if (domains[key]) continue;
355
- if (rule.domain) {
356
- domains[key] = { min: rule.domain[0], max: rule.domain[1] };
357
- continue;
358
- }
359
- let min = Number.POSITIVE_INFINITY;
360
- let max = Number.NEGATIVE_INFINITY;
361
- for (const n of model.nodes) {
362
- if (!matches(n, rule.when)) continue;
363
- const v = n[rule.field] ?? n.meta?.[rule.field];
364
- if (typeof v !== "number" || !Number.isFinite(v)) continue;
365
- min = Math.min(min, v);
366
- max = Math.max(max, v);
367
- }
368
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
369
- min = 0;
370
- max = 1;
371
- }
372
- domains[key] = { min, max };
373
- }
374
- for (const node of model.nodes) {
375
- const mesh = meshById.get(node.id);
376
- if (!mesh) continue;
377
- for (const rule of colorRules) {
378
- if (!matches(node, rule.when)) continue;
379
- mesh.traverse((obj) => {
380
- if (!obj.material) return;
381
- const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
382
- for (const m of mats) {
383
- if (m?.color) m.color = new THREE4.Color(rule.color);
384
- m.transparent = true;
385
- m.opacity = rule.opacity ?? 1;
386
- }
387
- });
388
- break;
389
- }
390
- for (const rule of sizeRules) {
391
- if (!matches(node, rule.when)) continue;
392
- const w = node[rule.field] ?? node.meta?.[rule.field];
393
- if (typeof w !== "number" || !Number.isFinite(w)) continue;
394
- const key = JSON.stringify(rule.when) + "|" + rule.field;
395
- const domain = domains[key];
396
- if (!domain) continue;
397
- const { min, max } = domain;
398
- const range = max - min;
399
- const t = range === 0 ? 0.5 : (w - min) / range;
400
- const clamped = Math.min(1, Math.max(0, t));
401
- const s = rule.scale[0] + clamped * (rule.scale[1] - rule.scale[0]);
402
- mesh.scale.setScalar(s);
403
- break;
404
- }
355
+ `;
405
356
  }
357
+ });
358
+ function createSmartMaterial(params) {
359
+ const uniforms = { ...globalUniforms, ...params.uniforms };
360
+ return new THREE4.ShaderMaterial({
361
+ uniforms,
362
+ vertexShader: `
363
+ ${BLEND_CHUNK}
364
+ varying vec2 vScreenPos;
365
+ ${params.vertexShaderBody}
366
+ `,
367
+ fragmentShader: `
368
+ ${MASK_CHUNK}
369
+ ${params.fragmentShader}
370
+ `,
371
+ transparent: params.transparent || false,
372
+ depthWrite: params.depthWrite !== void 0 ? params.depthWrite : true,
373
+ depthTest: params.depthTest !== void 0 ? params.depthTest : true,
374
+ side: params.side || THREE4.FrontSide,
375
+ blending: params.blending || THREE4.NormalBlending
376
+ });
406
377
  }
378
+ var globalUniforms;
407
379
  var init_materials = __esm({
408
380
  "src/engine/materials.ts"() {
381
+ init_shaders();
382
+ globalUniforms = {
383
+ uScale: { value: 1 },
384
+ uAspect: { value: 1 },
385
+ uBlend: { value: 0 }
386
+ };
409
387
  }
410
388
  });
411
389
 
@@ -420,380 +398,359 @@ function createEngine({
420
398
  onHover,
421
399
  onArrangementChange
422
400
  }) {
423
- const renderer = new THREE4.WebGLRenderer({ antialias: true });
401
+ const renderer = new THREE4.WebGLRenderer({ antialias: true, alpha: false });
424
402
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
425
- renderer.setClearColor(0, 1);
403
+ renderer.setSize(container.clientWidth, container.clientHeight);
426
404
  container.appendChild(renderer.domElement);
427
- renderer.domElement.style.width = "100%";
428
- renderer.domElement.style.height = "100%";
429
- renderer.domElement.style.touchAction = "none";
430
405
  const scene = new THREE4.Scene();
431
- const camera = new THREE4.PerspectiveCamera(90, 1, 0.1, 5e3);
432
- camera.position.set(0, 0, 0.01);
406
+ scene.background = new THREE4.Color(0);
407
+ const camera = new THREE4.PerspectiveCamera(60, 1, 0.1, 3e3);
408
+ camera.position.set(0, 0, 0);
433
409
  camera.up.set(0, 1, 0);
434
- const controls = new OrbitControls(camera, renderer.domElement);
435
- controls.target.set(0, 0, 0);
436
- controls.enableRotate = true;
437
- controls.enablePan = false;
438
- controls.enableZoom = false;
439
- controls.enableDamping = true;
440
- controls.dampingFactor = 0.07;
441
- controls.screenSpacePanning = false;
442
- const EPS = THREE4.MathUtils.degToRad(0.05);
443
- controls.minAzimuthAngle = -Infinity;
444
- controls.maxAzimuthAngle = Infinity;
445
- controls.minPolarAngle = Math.PI / 2 + EPS;
446
- controls.maxPolarAngle = Math.PI - EPS;
447
- controls.update();
448
- const env = {
449
- skyRadius: 2800,
450
- groundRadius: 995,
451
- timeScale: 60,
452
- // 1 = real-time; 60 = 60x faster drift
453
- defaultFov: 90,
454
- minFov: 6,
455
- maxFov: 110,
456
- // 0.02 works well for mouse wheels; trackpads are moderated via ctrlKey
457
- fovWheelSensitivity: 0.02,
458
- zoomLerp: 0.12,
459
- focusZoomFov: 18,
460
- focusDurationMs: 650};
461
- const SIDEREAL_RATE = THREE4.MathUtils.degToRad(15) / 3600;
462
- let lastFrameMs = performance.now();
463
- let targetFov = env.defaultFov;
464
- let zoomOutLimitFov = env.defaultFov;
410
+ let running = false;
411
+ let raf = 0;
412
+ const state = {
413
+ isDragging: false,
414
+ lastMouseX: 0,
415
+ lastMouseY: 0,
416
+ velocityX: 0,
417
+ velocityY: 0,
418
+ lat: 0.5,
419
+ lon: 0,
420
+ targetLat: 0.5,
421
+ targetLon: 0,
422
+ fov: ENGINE_CONFIG.defaultFov,
423
+ dragMode: "none",
424
+ draggedNodeId: null,
425
+ draggedStarIndex: -1,
426
+ draggedDist: 2e3,
427
+ draggedGroup: null
428
+ };
429
+ const mouseNDC = new THREE4.Vector2();
430
+ let isMouseInWindow = false;
431
+ let handlers = { onSelect, onHover, onArrangementChange };
432
+ let currentConfig;
433
+ function mix(a, b, t) {
434
+ return a * (1 - t) + b * t;
435
+ }
436
+ function getBlendFactor(fov) {
437
+ if (fov <= ENGINE_CONFIG.blendStart) return 0;
438
+ if (fov >= ENGINE_CONFIG.blendEnd) return 1;
439
+ let t = (fov - ENGINE_CONFIG.blendStart) / (ENGINE_CONFIG.blendEnd - ENGINE_CONFIG.blendStart);
440
+ return t * t * (3 - 2 * t);
441
+ }
442
+ function updateUniforms() {
443
+ const blend = getBlendFactor(state.fov);
444
+ globalUniforms.uBlend.value = blend;
445
+ const fovRad = state.fov * Math.PI / 180;
446
+ const scaleLinear = 1 / Math.tan(fovRad / 2);
447
+ const scaleStereo = 1 / (2 * Math.tan(fovRad / 4));
448
+ globalUniforms.uScale.value = mix(scaleLinear, scaleStereo, blend);
449
+ globalUniforms.uAspect.value = camera.aspect;
450
+ camera.fov = Math.min(state.fov, ENGINE_CONFIG.defaultFov);
451
+ camera.updateProjectionMatrix();
452
+ }
453
+ function getMouseViewVector(fovDeg, aspectRatio) {
454
+ const blend = getBlendFactor(fovDeg);
455
+ const fovRad = fovDeg * Math.PI / 180;
456
+ const uvX = mouseNDC.x * aspectRatio;
457
+ const uvY = mouseNDC.y;
458
+ const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
459
+ const halfHeightLinear = Math.tan(fovRad / 2);
460
+ const theta_lin = Math.atan(r_uv * halfHeightLinear);
461
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
462
+ const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
463
+ const theta = mix(theta_lin, theta_str, blend);
464
+ const phi = Math.atan2(uvY, uvX);
465
+ const sinTheta = Math.sin(theta);
466
+ const cosTheta = Math.cos(theta);
467
+ return new THREE4.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
468
+ }
469
+ function getMouseWorldVector(pixelX, pixelY, width, height) {
470
+ const aspect = width / height;
471
+ const ndcX = pixelX / width * 2 - 1;
472
+ const ndcY = -(pixelY / height) * 2 + 1;
473
+ const blend = getBlendFactor(state.fov);
474
+ const fovRad = state.fov * Math.PI / 180;
475
+ const uvX = ndcX * aspect;
476
+ const uvY = ndcY;
477
+ const r_uv = Math.sqrt(uvX * uvX + uvY * uvY);
478
+ const halfHeightLinear = Math.tan(fovRad / 2);
479
+ const theta_lin = Math.atan(r_uv * halfHeightLinear);
480
+ const halfHeightStereo = 2 * Math.tan(fovRad / 4);
481
+ const theta_str = 2 * Math.atan(r_uv * halfHeightStereo / 2);
482
+ const theta = mix(theta_lin, theta_str, blend);
483
+ const phi = Math.atan2(uvY, uvX);
484
+ const sinTheta = Math.sin(theta);
485
+ const cosTheta = Math.cos(theta);
486
+ const vView = new THREE4.Vector3(sinTheta * Math.cos(phi), sinTheta * Math.sin(phi), -cosTheta).normalize();
487
+ return vView.applyQuaternion(camera.quaternion);
488
+ }
489
+ function smartProjectJS(worldPos) {
490
+ const viewPos = worldPos.clone().applyMatrix4(camera.matrixWorldInverse);
491
+ const dir = viewPos.clone().normalize();
492
+ const zLinear = Math.max(0.01, -dir.z);
493
+ const kStereo = 2 / (1 - dir.z);
494
+ const kLinear = 1 / zLinear;
495
+ const blend = globalUniforms.uBlend.value;
496
+ const k = mix(kLinear, kStereo, blend);
497
+ return { x: k * dir.x, y: k * dir.y, z: dir.z };
498
+ }
465
499
  const groundGroup = new THREE4.Group();
466
500
  scene.add(groundGroup);
467
- function buildGroundHemisphere(radius) {
501
+ function createGround() {
468
502
  groundGroup.clear();
469
- const hemi = new THREE4.SphereGeometry(radius, 64, 32, 0, Math.PI * 2, Math.PI / 2, Math.PI);
470
- hemi.scale(-1, 1, 1);
471
- const count = hemi.attributes.position.count;
472
- const colors = new Float32Array(count * 3);
473
- const pos = hemi.attributes.position;
474
- const c = new THREE4.Color();
475
- for (let i = 0; i < count; i++) {
476
- const y = pos.getY(i);
477
- const t = THREE4.MathUtils.clamp(1 - Math.abs(y) / radius, 0, 1);
478
- c.setRGB(
479
- THREE4.MathUtils.lerp(6 / 255, 21 / 255, t),
480
- THREE4.MathUtils.lerp(16 / 255, 35 / 255, t),
481
- THREE4.MathUtils.lerp(23 / 255, 53 / 255, t)
482
- );
483
- colors.set([c.r, c.g, c.b], i * 3);
484
- }
485
- hemi.setAttribute("color", new THREE4.BufferAttribute(colors, 3));
486
- const groundMat = new THREE4.MeshBasicMaterial({
487
- vertexColors: true,
488
- side: THREE4.FrontSide,
503
+ const radius = 995;
504
+ const geometry = new THREE4.SphereGeometry(radius, 128, 64, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2);
505
+ 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,
510
+ transparent: false,
489
511
  depthWrite: true,
490
- // important: occlude stars under horizon
491
512
  depthTest: true
492
513
  });
493
- const hemiMesh = new THREE4.Mesh(hemi, groundMat);
494
- groundGroup.add(hemiMesh);
495
- {
496
- const inner = radius * 0.985;
497
- const outer = radius * 1.005;
498
- const ringGeo = new THREE4.RingGeometry(inner, outer, 128);
499
- ringGeo.rotateX(-Math.PI / 2);
500
- const ringMat = new THREE4.ShaderMaterial({
501
- transparent: true,
502
- depthWrite: false,
503
- uniforms: {
504
- uInner: { value: inner },
505
- uOuter: { value: outer },
506
- uColor: { value: new THREE4.Color(8037119) }
507
- },
508
- vertexShader: `
509
- varying vec2 vXY;
510
- void main() {
511
- vXY = position.xz;
512
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
513
- }
514
- `,
515
- fragmentShader: `
516
- uniform float uInner;
517
- uniform float uOuter;
518
- uniform vec3 uColor;
519
- varying vec2 vXY;
520
-
521
- void main() {
522
- float r = length(vXY);
523
- float t = clamp((r - uInner) / (uOuter - uInner), 0.0, 1.0);
524
- // peak glow near inner edge, fade outwards
525
- float a = pow(1.0 - t, 2.2) * 0.35;
526
- gl_FragColor = vec4(uColor, a);
527
- }
528
- `
529
- });
530
- const ring = new THREE4.Mesh(ringGeo, ringMat);
531
- ring.position.y = 0;
532
- groundGroup.add(ring);
514
+ const ground = new THREE4.Mesh(geometry, material);
515
+ 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);
533
524
  }
534
525
  }
535
- buildGroundHemisphere(env.groundRadius);
526
+ function createAtmosphere() {
527
+ const geometry = new THREE4.SphereGeometry(990, 128, 64);
528
+ const material = createSmartMaterial({
529
+ uniforms: { top: { value: new THREE4.Color(0) }, bot: { value: new THREE4.Color(1712172) } },
530
+ 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
+ `,
547
+ 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,
571
+ depthWrite: false,
572
+ depthTest: true
573
+ });
574
+ const atm = new THREE4.Mesh(geometry, material);
575
+ groundGroup.add(atm);
576
+ }
536
577
  const backdropGroup = new THREE4.Group();
537
578
  scene.add(backdropGroup);
538
- function buildBackdropStars(radius, count) {
579
+ function createBackdropStars() {
539
580
  backdropGroup.clear();
540
- const starGeo = new THREE4.BufferGeometry();
541
- const starPos = new Float32Array(count * 3);
542
- for (let i = 0; i < count; i++) {
543
- const r = radius + Math.random() * (radius * 0.5);
544
- const theta = Math.random() * Math.PI * 2;
545
- const phi = Math.acos(2 * Math.random() - 1);
546
- starPos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
547
- starPos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
548
- starPos[i * 3 + 2] = r * Math.cos(phi);
581
+ const geometry = new THREE4.BufferGeometry();
582
+ const positions = [];
583
+ const sizes = [];
584
+ 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
+ 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
+ }
617
+ positions.push(x, y, z);
618
+ const size = 0.5 + -Math.log(Math.random()) * 0.8 * 1.5;
619
+ 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);
549
623
  }
550
- starGeo.setAttribute("position", new THREE4.BufferAttribute(starPos, 3));
551
- const starMat = new THREE4.PointsMaterial({
552
- color: 10135218,
553
- size: 0.5,
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));
627
+ const material = createSmartMaterial({
628
+ uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
629
+ vertexShaderBody: `
630
+ attribute float size;
631
+ attribute vec3 color;
632
+ varying vec3 vColor;
633
+ uniform float pixelRatio;
634
+ void main() {
635
+ vColor = color;
636
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
637
+ gl_Position = smartProject(mvPosition);
638
+ vScreenPos = gl_Position.xy / gl_Position.w;
639
+ gl_PointSize = size * pixelRatio * (600.0 / -mvPosition.z);
640
+ }
641
+ `,
642
+ fragmentShader: `
643
+ varying vec3 vColor;
644
+ void main() {
645
+ vec2 coord = gl_PointCoord - vec2(0.5);
646
+ float dist = length(coord) * 2.0;
647
+ if (dist > 1.0) discard;
648
+ float alphaMask = getMaskAlpha();
649
+ if (alphaMask < 0.01) discard;
650
+ // Use same Gaussian glow for backdrop
651
+ float alpha = exp(-3.0 * dist * dist);
652
+ gl_FragColor = vec4(vColor, alpha * alphaMask);
653
+ }
654
+ `,
554
655
  transparent: true,
555
- opacity: 0.55,
556
- depthWrite: false
656
+ depthWrite: false,
657
+ depthTest: true
557
658
  });
558
- const stars = new THREE4.Points(starGeo, starMat);
559
- backdropGroup.add(stars);
659
+ const points = new THREE4.Points(geometry, material);
660
+ points.frustumCulled = false;
661
+ backdropGroup.add(points);
560
662
  }
561
- buildBackdropStars(env.skyRadius, 6500);
663
+ createGround();
664
+ createAtmosphere();
665
+ createBackdropStars();
562
666
  const raycaster = new THREE4.Raycaster();
563
- const pointer = new THREE4.Vector2();
667
+ raycaster.params.Points.threshold = 5;
668
+ new THREE4.Vector2();
564
669
  const root = new THREE4.Group();
565
670
  scene.add(root);
566
- let raf = 0;
567
- let running = false;
568
- let handlers = { onSelect, onHover, onArrangementChange };
569
- let hoveredId = null;
570
- let isDragging = false;
571
- let dragControls = null;
572
- let currentConfig;
573
671
  const nodeById = /* @__PURE__ */ new Map();
574
- const meshById = /* @__PURE__ */ new Map();
575
- const lineByBookId = /* @__PURE__ */ new Map();
576
- const dynamicObjects = [];
577
- function getFullArrangement() {
578
- const arr = {};
579
- for (const [id, mesh] of meshById.entries()) {
580
- arr[id] = {
581
- position: [mesh.position.x, mesh.position.y, mesh.position.z]
582
- };
583
- }
584
- return arr;
585
- }
586
- function updateBookLine(bookId) {
587
- const line = lineByBookId.get(bookId);
588
- if (!line) return;
589
- const chapters = [];
590
- for (const n of nodeById.values()) {
591
- if (n.parent === bookId && n.level === 3) {
592
- chapters.push(n);
593
- }
594
- }
595
- chapters.sort((a, b) => {
596
- const cA = a.meta?.chapter || 0;
597
- const cB = b.meta?.chapter || 0;
598
- return cA - cB;
599
- });
600
- const points = [];
601
- for (const c of chapters) {
602
- const m = meshById.get(c.id);
603
- if (m) {
604
- points.push(m.position.clone());
605
- }
606
- }
607
- if (points.length > 1) {
608
- line.geometry.setFromPoints(points);
609
- }
610
- }
611
- function updateDragControls(editable) {
612
- if (!editable) {
613
- if (dragControls) {
614
- dragControls.dispose();
615
- dragControls = null;
616
- }
617
- return;
618
- }
619
- const draggables = [];
620
- for (const [id, mesh] of meshById.entries()) {
621
- const node = nodeById.get(id);
622
- if (node?.level === 3) {
623
- draggables.push(mesh);
624
- } else if (node?.level === 2 && mesh instanceof THREE4.Sprite) {
625
- draggables.push(mesh);
672
+ const starIndexToId = [];
673
+ const dynamicLabels = [];
674
+ let constellationLines = null;
675
+ let starPoints = null;
676
+ function clearRoot() {
677
+ for (const child of [...root.children]) {
678
+ root.remove(child);
679
+ if (child.geometry) child.geometry.dispose();
680
+ if (child.material) {
681
+ const m = child.material;
682
+ if (Array.isArray(m)) m.forEach((mm) => mm.dispose());
683
+ else m.dispose();
626
684
  }
627
685
  }
628
- if (dragControls) {
629
- dragControls.dispose();
630
- }
631
- dragControls = new DragControls(draggables, camera, renderer.domElement);
632
- const lastPos = new THREE4.Vector3();
633
- dragControls.addEventListener("dragstart", (event) => {
634
- controls.enabled = false;
635
- isDragging = true;
636
- lastPos.copy(event.object.position);
637
- });
638
- dragControls.addEventListener("drag", (event) => {
639
- const obj = event.object;
640
- const id = obj.userData.id;
641
- const node = nodeById.get(id);
642
- if (node && node.level === 2) {
643
- const currentPos = obj.position;
644
- const delta = new THREE4.Vector3().subVectors(currentPos, lastPos);
645
- const bookId = id;
646
- const childStars = [];
647
- for (const [nId, n] of nodeById.entries()) {
648
- if (n.parent === bookId && n.level === 3) {
649
- const starMesh = meshById.get(nId);
650
- if (starMesh) childStars.push(starMesh);
651
- }
652
- }
653
- for (const star of childStars) {
654
- star.position.add(delta);
655
- }
656
- updateBookLine(bookId);
657
- lastPos.copy(currentPos);
658
- } else if (node && node.level === 3) {
659
- if (node.parent) {
660
- updateBookLine(node.parent);
661
- }
662
- }
663
- });
664
- dragControls.addEventListener("dragend", (event) => {
665
- controls.enabled = true;
666
- setTimeout(() => {
667
- isDragging = false;
668
- }, 0);
669
- const obj = event.object;
670
- const id = obj.userData.id;
671
- if (id && currentConfig) {
672
- handlers.onArrangementChange?.(getFullArrangement());
673
- }
674
- });
675
- }
676
- function resize() {
677
- const w = container.clientWidth || 1;
678
- const h = container.clientHeight || 1;
679
- renderer.setSize(w, h, false);
680
- camera.aspect = w / h;
681
- camera.updateProjectionMatrix();
682
- }
683
- function disposeObject(obj) {
684
- obj.traverse((o) => {
685
- if (o.geometry) o.geometry.dispose?.();
686
- if (o.material) {
687
- const mats = Array.isArray(o.material) ? o.material : [o.material];
688
- mats.forEach((m) => {
689
- if (m.map) m.map.dispose?.();
690
- m.dispose?.();
691
- });
692
- }
693
- });
686
+ nodeById.clear();
687
+ starIndexToId.length = 0;
688
+ dynamicLabels.length = 0;
689
+ constellationLines = null;
690
+ starPoints = null;
694
691
  }
695
- function createTextSprite(text, color = "#ffffff") {
692
+ function createTextTexture(text, color = "#ffffff") {
696
693
  const canvas = document.createElement("canvas");
697
694
  const ctx = canvas.getContext("2d");
698
695
  if (!ctx) return null;
699
696
  const fontSize = 96;
700
- const font = `bold ${fontSize}px sans-serif`;
701
- ctx.font = font;
697
+ ctx.font = `bold ${fontSize}px sans-serif`;
702
698
  const metrics = ctx.measureText(text);
703
699
  const w = Math.ceil(metrics.width);
704
700
  const h = Math.ceil(fontSize * 1.2);
705
701
  canvas.width = w;
706
702
  canvas.height = h;
707
- ctx.font = font;
703
+ ctx.font = `bold ${fontSize}px sans-serif`;
708
704
  ctx.fillStyle = color;
709
705
  ctx.textAlign = "center";
710
706
  ctx.textBaseline = "middle";
711
707
  ctx.fillText(text, w / 2, h / 2);
712
708
  const tex = new THREE4.CanvasTexture(canvas);
713
709
  tex.minFilter = THREE4.LinearFilter;
714
- const mat = new THREE4.SpriteMaterial({
715
- map: tex,
716
- transparent: true,
717
- depthWrite: false,
718
- depthTest: true
719
- });
720
- const sprite = new THREE4.Sprite(mat);
721
- const targetHeight = 4;
722
- const aspect = w / h;
723
- sprite.scale.set(targetHeight * aspect, targetHeight, 1);
724
- return sprite;
710
+ return { tex, aspect: w / h };
725
711
  }
726
- function createStarTexture() {
727
- const canvas = document.createElement("canvas");
728
- canvas.width = 64;
729
- canvas.height = 64;
730
- const ctx = canvas.getContext("2d");
731
- const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
732
- gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
733
- gradient.addColorStop(0.3, "rgba(255, 255, 255, 0.9)");
734
- gradient.addColorStop(0.6, "rgba(255, 255, 255, 0.4)");
735
- gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
736
- ctx.fillStyle = gradient;
737
- ctx.fillRect(0, 0, 64, 64);
738
- const tex = new THREE4.CanvasTexture(canvas);
739
- return tex;
740
- }
741
- const starTexture = createStarTexture();
742
- function clearRoot() {
743
- for (const child of [...root.children]) {
744
- root.remove(child);
745
- disposeObject(child);
712
+ function getPosition(n) {
713
+ if (currentConfig?.arrangement) {
714
+ const arr = currentConfig.arrangement[n.id];
715
+ if (arr) return new THREE4.Vector3(arr.position[0], arr.position[1], arr.position[2]);
746
716
  }
747
- nodeById.clear();
748
- meshById.clear();
749
- lineByBookId.clear();
750
- dynamicObjects.length = 0;
717
+ return new THREE4.Vector3(n.meta?.x ?? 0, n.meta?.y ?? 0, n.meta?.z ?? 0);
718
+ }
719
+ function getBoundaryPoint(angle, t, radius) {
720
+ const y = 0.05 + t * (1 - 0.05);
721
+ const rY = Math.sqrt(1 - y * y);
722
+ const x = Math.cos(angle) * rY;
723
+ const z = Math.sin(angle) * rY;
724
+ return new THREE4.Vector3(x, y, z).multiplyScalar(radius);
751
725
  }
752
726
  function buildFromModel(model, cfg) {
753
727
  clearRoot();
754
- if (cfg.background && cfg.background !== "transparent") {
755
- scene.background = new THREE4.Color(cfg.background);
756
- } else {
757
- scene.background = null;
758
- }
759
- camera.fov = cfg.camera?.fov ?? env.defaultFov;
760
- camera.updateProjectionMatrix();
761
- targetFov = camera.fov;
762
- zoomOutLimitFov = camera.fov;
763
- const layoutCfg = {
764
- ...cfg.layout,
765
- radius: cfg.layout?.radius ?? 2e3
766
- };
728
+ scene.background = cfg.background && cfg.background !== "transparent" ? new THREE4.Color(cfg.background) : new THREE4.Color(0);
729
+ const layoutCfg = { ...cfg.layout, radius: cfg.layout?.radius ?? 2e3 };
767
730
  const laidOut = computeLayoutPositions(model, layoutCfg);
768
- const boundaries = laidOut.meta?.divisionBoundaries ?? [];
769
- if (boundaries.length > 0) {
770
- const boundaryMat = new THREE4.LineBasicMaterial({
771
- color: 5601177,
772
- transparent: true,
773
- opacity: 0.15,
774
- depthWrite: false,
775
- blending: THREE4.AdditiveBlending
776
- });
777
- boundaries.forEach((angle) => {
778
- const points = [];
779
- const steps = 32;
780
- for (let i = 0; i <= steps; i++) {
781
- const t = i / steps;
782
- const y = 0.05 + t * (1 - 0.05);
783
- const rY = Math.sqrt(1 - y * y);
784
- const x = Math.cos(angle) * rY;
785
- const z = Math.sin(angle) * rY;
786
- const pos = new THREE4.Vector3(x, y, z).multiplyScalar(layoutCfg.radius);
787
- points.push(pos);
788
- }
789
- const geo = new THREE4.BufferGeometry().setFromPoints(points);
790
- const line = new THREE4.Line(geo, boundaryMat);
791
- root.add(line);
792
- });
793
- }
731
+ const starPositions = [];
732
+ const starSizes = [];
733
+ const starColors = [];
734
+ 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),
742
+ // 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
749
+ ];
794
750
  let minWeight = Infinity;
795
751
  let maxWeight = -Infinity;
796
752
  for (const n of laidOut.nodes) {
753
+ nodeById.set(n.id, n);
797
754
  if (n.level === 3 && typeof n.weight === "number") {
798
755
  if (n.weight < minWeight) minWeight = n.weight;
799
756
  if (n.weight > maxWeight) maxWeight = n.weight;
@@ -806,75 +763,113 @@ function createEngine({
806
763
  maxWeight = minWeight + 1;
807
764
  }
808
765
  for (const n of laidOut.nodes) {
809
- nodeById.set(n.id, n);
810
- let x = n.meta?.x ?? 0;
811
- let y = n.meta?.y ?? 0;
812
- let z = n.meta?.z ?? 0;
813
- if (cfg.arrangement) {
814
- const arr = cfg.arrangement[n.id];
815
- if (arr) {
816
- const pos = arr.position;
817
- x = pos[0];
818
- y = pos[1];
819
- z = pos[2];
820
- }
821
- }
822
766
  if (n.level === 3) {
823
- const mat = new THREE4.SpriteMaterial({
824
- map: starTexture,
825
- color: 16777215,
826
- transparent: true,
827
- blending: THREE4.AdditiveBlending,
828
- depthWrite: false
829
- });
830
- const sprite = new THREE4.Sprite(mat);
831
- sprite.position.set(x, y, z);
832
- sprite.userData = { id: n.id, level: n.level };
833
- let baseScale = 3.5;
767
+ const p = getPosition(n);
768
+ starPositions.push(p.x, p.y, p.z);
769
+ starIndexToId.push(n.id);
770
+ let baseSize = 3.5;
834
771
  if (typeof n.weight === "number") {
835
772
  const t = (n.weight - minWeight) / (maxWeight - minWeight);
836
- baseScale = 3 + t * 4;
837
- }
838
- sprite.scale.setScalar(baseScale);
839
- dynamicObjects.push({ obj: sprite, initialScale: sprite.scale.clone(), type: "star" });
840
- if (n.label) {
841
- const labelSprite = createTextSprite(n.label);
842
- if (labelSprite) {
843
- labelSprite.position.set(0, 3, 0);
844
- labelSprite.scale.multiplyScalar(1.33);
845
- labelSprite.visible = false;
846
- sprite.add(labelSprite);
847
- }
773
+ baseSize = 3 + t * 4;
848
774
  }
849
- root.add(sprite);
850
- meshById.set(n.id, sprite);
851
- } else if (n.level === 1 || n.level === 2) {
852
- if (n.label) {
853
- const isBook = n.level === 2;
854
- const color = isBook ? "#ffffff" : "#38bdf8";
855
- const baseScale = isBook ? 6 : 4.66;
856
- const labelSprite = createTextSprite(n.label, color);
857
- if (labelSprite) {
858
- labelSprite.position.set(x, y, z);
859
- labelSprite.scale.multiplyScalar(baseScale);
860
- root.add(labelSprite);
861
- if (isBook) {
862
- labelSprite.userData = { id: n.id, level: n.level, interactive: true };
863
- meshById.set(n.id, labelSprite);
864
- dynamicObjects.push({ obj: labelSprite, initialScale: labelSprite.scale.clone(), type: "label" });
865
- }
866
- }
775
+ starSizes.push(baseSize);
776
+ const colorIdx = Math.floor(Math.pow(Math.random(), 1.5) * SPECTRAL_COLORS.length);
777
+ const c = SPECTRAL_COLORS[Math.min(colorIdx, SPECTRAL_COLORS.length - 1)];
778
+ 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);
782
+ if (texRes) {
783
+ const baseScale = 0.05;
784
+ const size = new THREE4.Vector2(baseScale * texRes.aspect, baseScale);
785
+ const mat = createSmartMaterial({
786
+ uniforms: {
787
+ uMap: { value: texRes.tex },
788
+ uSize: { value: size },
789
+ uAlpha: { value: 0 }
790
+ },
791
+ vertexShaderBody: `
792
+ uniform vec2 uSize;
793
+ varying vec2 vUv;
794
+ void main() {
795
+ vUv = uv;
796
+ vec4 mvPos = modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0);
797
+ vec4 projected = smartProject(mvPos);
798
+ vec2 offset = position.xy * uSize;
799
+ projected.xy += offset / vec2(uAspect, 1.0);
800
+ gl_Position = projected;
801
+ }
802
+ `,
803
+ fragmentShader: `
804
+ uniform sampler2D uMap;
805
+ uniform float uAlpha;
806
+ varying vec2 vUv;
807
+ void main() {
808
+ float mask = getMaskAlpha();
809
+ if (mask < 0.01) discard;
810
+ vec4 tex = texture2D(uMap, vUv);
811
+ gl_FragColor = vec4(tex.rgb, tex.a * uAlpha * mask);
812
+ }
813
+ `,
814
+ transparent: true,
815
+ depthWrite: false,
816
+ depthTest: true
817
+ });
818
+ const mesh = new THREE4.Mesh(new THREE4.PlaneGeometry(1, 1), mat);
819
+ const p = getPosition(n);
820
+ mesh.position.set(p.x, p.y, p.z);
821
+ mesh.scale.set(size.x, size.y, 1);
822
+ mesh.frustumCulled = false;
823
+ mesh.userData = { id: n.id };
824
+ root.add(mesh);
825
+ dynamicLabels.push({ obj: mesh, node: n, initialScale: size.clone() });
867
826
  }
868
827
  }
869
828
  }
870
- applyVisuals({ model: laidOut, cfg, meshById });
871
- const lineMat = new THREE4.LineBasicMaterial({
872
- color: 4478310,
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));
833
+ const starMat = createSmartMaterial({
834
+ uniforms: { pixelRatio: { value: renderer.getPixelRatio() } },
835
+ vertexShaderBody: `
836
+ attribute float size;
837
+ attribute vec3 color;
838
+ varying vec3 vColor;
839
+ uniform float pixelRatio;
840
+ void main() {
841
+ vColor = color;
842
+ vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
843
+ gl_Position = smartProject(mvPosition);
844
+ vScreenPos = gl_Position.xy / gl_Position.w;
845
+ gl_PointSize = size * pixelRatio * (2000.0 / -mvPosition.z);
846
+ }
847
+ `,
848
+ fragmentShader: `
849
+ varying vec3 vColor;
850
+ void main() {
851
+ 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;
855
+
856
+ float alphaMask = getMaskAlpha();
857
+ if (alphaMask < 0.01) discard;
858
+
859
+ // Gaussian Glow: Sharp core, soft halo
860
+ float alpha = exp(-3.0 * dist * dist);
861
+
862
+ gl_FragColor = vec4(vColor, alpha * alphaMask);
863
+ }
864
+ `,
873
865
  transparent: true,
874
- opacity: 0.3,
875
866
  depthWrite: false,
876
- blending: THREE4.AdditiveBlending
867
+ depthTest: true
877
868
  });
869
+ starPoints = new THREE4.Points(starGeo, starMat);
870
+ starPoints.frustumCulled = false;
871
+ root.add(starPoints);
872
+ const linePoints = [];
878
873
  const bookMap = /* @__PURE__ */ new Map();
879
874
  for (const n of laidOut.nodes) {
880
875
  if (n.level === 3 && n.parent) {
@@ -883,94 +878,63 @@ function createEngine({
883
878
  bookMap.set(n.parent, list);
884
879
  }
885
880
  }
886
- for (const [bookId, chapters] of bookMap.entries()) {
887
- chapters.sort((a, b) => {
888
- const cA = a.meta?.chapter || 0;
889
- const cB = b.meta?.chapter || 0;
890
- return cA - cB;
891
- });
881
+ for (const chapters of bookMap.values()) {
882
+ chapters.sort((a, b) => (a.meta?.chapter || 0) - (b.meta?.chapter || 0));
892
883
  if (chapters.length < 2) continue;
893
- const points = [];
894
- for (const c of chapters) {
895
- const x = c.meta?.x ?? 0;
896
- const y = c.meta?.y ?? 0;
897
- const z = c.meta?.z ?? 0;
898
- points.push(new THREE4.Vector3(x, y, z));
899
- }
900
- const geo = new THREE4.BufferGeometry().setFromPoints(points);
901
- const line = new THREE4.Line(geo, lineMat);
902
- root.add(line);
903
- lineByBookId.set(bookId, line);
904
- }
905
- resize();
906
- }
907
- function applyFocus(targetId, animate = true) {
908
- if (!targetId) {
909
- for (const [id, mesh] of meshById.entries()) {
910
- mesh.traverse((obj) => {
911
- if (obj.material) obj.material.opacity = 1;
912
- });
913
- mesh.userData.interactive = true;
914
- }
915
- for (const line of lineByBookId.values()) {
916
- line.visible = true;
917
- line.material.opacity = 0.3;
918
- }
919
- return;
920
- }
921
- const childrenMap = /* @__PURE__ */ new Map();
922
- for (const n of nodeById.values()) {
923
- if (n.parent) {
924
- const list = childrenMap.get(n.parent) ?? [];
925
- list.push(n.id);
926
- childrenMap.set(n.parent, list);
884
+ for (let i = 0; i < chapters.length - 1; i++) {
885
+ const c1 = chapters[i];
886
+ const c2 = chapters[i + 1];
887
+ if (!c1 || !c2) continue;
888
+ const p1 = getPosition(c1);
889
+ const p2 = getPosition(c2);
890
+ linePoints.push(p1.x, p1.y, p1.z);
891
+ linePoints.push(p2.x, p2.y, p2.z);
927
892
  }
928
893
  }
929
- const activeIds = /* @__PURE__ */ new Set();
930
- const queue = [targetId];
931
- activeIds.add(targetId);
932
- while (queue.length > 0) {
933
- const curr = queue.pop();
934
- const kids = childrenMap.get(curr);
935
- if (kids) {
936
- for (const k of kids) {
937
- activeIds.add(k);
938
- queue.push(k);
939
- }
940
- }
941
- }
942
- for (const [id, mesh] of meshById.entries()) {
943
- const isActive = activeIds.has(id);
944
- const opacity = isActive ? 1 : 0.1;
945
- mesh.traverse((obj) => {
946
- if (obj.material) obj.material.opacity = opacity;
894
+ if (linePoints.length > 0) {
895
+ const lineGeo = new THREE4.BufferGeometry();
896
+ lineGeo.setAttribute("position", new THREE4.Float32BufferAttribute(linePoints, 3));
897
+ const lineMat = createSmartMaterial({
898
+ uniforms: { color: { value: new THREE4.Color(4478310) } },
899
+ 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); }`,
901
+ transparent: true,
902
+ depthWrite: false,
903
+ blending: THREE4.AdditiveBlending
947
904
  });
948
- mesh.userData.interactive = isActive;
949
- }
950
- for (const [bookId, line] of lineByBookId.entries()) {
951
- const isActive = activeIds.has(bookId);
952
- line.material.opacity = isActive ? 0.3 : 0.05;
905
+ constellationLines = new THREE4.LineSegments(lineGeo, lineMat);
906
+ constellationLines.frustumCulled = false;
907
+ root.add(constellationLines);
953
908
  }
954
- if (animate) {
955
- const targetMesh = meshById.get(targetId);
956
- if (targetMesh) {
957
- animateFocusTo(targetMesh);
958
- } else {
959
- const sum = new THREE4.Vector3();
960
- let count = 0;
961
- for (const id of activeIds) {
962
- const mesh = meshById.get(id);
963
- if (mesh) {
964
- sum.add(mesh.getWorldPosition(new THREE4.Vector3()));
965
- count++;
966
- }
967
- }
968
- if (count > 0) {
969
- const centroid = sum.divideScalar(count);
970
- animateFocusTo(centroid);
909
+ const boundaries = laidOut.meta?.divisionBoundaries ?? [];
910
+ if (boundaries.length > 0) {
911
+ const boundaryMat = createSmartMaterial({
912
+ uniforms: { color: { value: new THREE4.Color(5601177) } },
913
+ 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
+ fragmentShader: `varying vec3 vColor; void main() { float alphaMask = getMaskAlpha(); if (alphaMask < 0.01) discard; gl_FragColor = vec4(vColor, 0.10 * alphaMask); }`,
915
+ transparent: true,
916
+ depthWrite: false,
917
+ blending: THREE4.AdditiveBlending
918
+ });
919
+ const boundaryGeo = new THREE4.BufferGeometry();
920
+ const bPoints = [];
921
+ boundaries.forEach((angle) => {
922
+ const steps = 32;
923
+ for (let i = 0; i < steps; i++) {
924
+ const t1 = i / steps;
925
+ const t2 = (i + 1) / steps;
926
+ const p1 = getBoundaryPoint(angle, t1, layoutCfg.radius);
927
+ const p2 = getBoundaryPoint(angle, t2, layoutCfg.radius);
928
+ bPoints.push(p1.x, p1.y, p1.z);
929
+ bPoints.push(p2.x, p2.y, p2.z);
971
930
  }
972
- }
931
+ });
932
+ boundaryGeo.setAttribute("position", new THREE4.Float32BufferAttribute(bPoints, 3));
933
+ const boundaryLines = new THREE4.LineSegments(boundaryGeo, boundaryMat);
934
+ boundaryLines.frustumCulled = false;
935
+ root.add(boundaryLines);
973
936
  }
937
+ resize();
974
938
  }
975
939
  let lastData = void 0;
976
940
  let lastAdapter = void 0;
@@ -997,268 +961,370 @@ function createEngine({
997
961
  }
998
962
  if (shouldRebuild && model) {
999
963
  buildFromModel(model, cfg);
1000
- } else if (cfg.arrangement) {
1001
- for (const [id, val] of Object.entries(cfg.arrangement)) {
1002
- const mesh = meshById.get(id);
1003
- if (mesh) {
1004
- mesh.position.set(val.position[0], val.position[1], val.position[2]);
1005
- }
1006
- }
1007
- }
1008
- if (cfg.focus?.nodeId) {
1009
- applyFocus(cfg.focus.nodeId, cfg.focus.animate);
1010
- } else {
1011
- applyFocus(void 0, false);
964
+ } else if (cfg.arrangement && starPoints) {
965
+ if (lastModel) buildFromModel(lastModel, cfg);
1012
966
  }
1013
- updateDragControls(!!cfg.editable);
1014
967
  }
1015
968
  function setHandlers(next) {
1016
969
  handlers = next;
1017
970
  }
1018
- function pick(ev) {
1019
- const rect = renderer.domElement.getBoundingClientRect();
1020
- pointer.x = (ev.clientX - rect.left) / rect.width * 2 - 1;
1021
- pointer.y = -((ev.clientY - rect.top) / rect.height * 2 - 1);
1022
- raycaster.setFromCamera(pointer, camera);
1023
- const hits = raycaster.intersectObjects(root.children, true);
1024
- const hit = hits.find(
1025
- (h) => (h.object.type === "Mesh" || h.object.type === "Sprite") && h.object.userData.interactive !== false
1026
- );
1027
- const id = hit?.object?.userData?.id;
1028
- return id ? nodeById.get(id) : void 0;
1029
- }
1030
- function onPointerMove(ev) {
1031
- const node = pick(ev);
1032
- const nextId = node?.id ?? null;
1033
- if (nextId !== hoveredId) {
1034
- if (hoveredId) {
1035
- const prevMesh = meshById.get(hoveredId);
1036
- const prevNode = nodeById.get(hoveredId);
1037
- if (prevMesh && prevNode && prevNode.level === 3) {
1038
- const label = prevMesh.children.find((c) => c instanceof THREE4.Sprite);
1039
- if (label) label.visible = false;
1040
- }
1041
- }
1042
- hoveredId = nextId;
1043
- if (nextId) {
1044
- const mesh = meshById.get(nextId);
1045
- const n = nodeById.get(nextId);
1046
- if (mesh && n && n.level === 3) {
1047
- const label = mesh.children.find((c) => c instanceof THREE4.Sprite);
1048
- if (label) {
1049
- label.visible = true;
1050
- label.position.set(0, 3, 0);
1051
- }
1052
- if (n.parent) {
1053
- const line = lineByBookId.get(n.parent);
1054
- if (line) line.material.opacity = 0.8;
971
+ function getFullArrangement() {
972
+ const arr = {};
973
+ if (starPoints && starPoints.geometry.attributes.position) {
974
+ const positions = starPoints.geometry.attributes.position.array;
975
+ for (let i = 0; i < starIndexToId.length; i++) {
976
+ const id = starIndexToId[i];
977
+ 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] };
1055
983
  }
1056
984
  }
1057
985
  }
1058
- handlers.onHover?.(node);
1059
986
  }
987
+ for (const item of dynamicLabels) {
988
+ arr[item.node.id] = { position: [item.obj.position.x, item.obj.position.y, item.obj.position.z] };
989
+ }
990
+ return arr;
1060
991
  }
1061
- function onPointerDown() {
1062
- isDragging = false;
1063
- }
1064
- function onChange() {
1065
- isDragging = true;
992
+ function pick(ev) {
993
+ const rect = renderer.domElement.getBoundingClientRect();
994
+ const mX = ev.clientX - rect.left;
995
+ const mY = ev.clientY - rect.top;
996
+ mouseNDC.x = mX / rect.width * 2 - 1;
997
+ mouseNDC.y = -(mY / rect.height) * 2 + 1;
998
+ let closestLabel = null;
999
+ let minLabelDist = 40;
1000
+ const uScale = globalUniforms.uScale.value;
1001
+ const uAspect = camera.aspect;
1002
+ const w = rect.width;
1003
+ const h = rect.height;
1004
+ for (const item of dynamicLabels) {
1005
+ if (!item.obj.visible) continue;
1006
+ const pWorld = item.obj.position;
1007
+ const pProj = smartProjectJS(pWorld);
1008
+ const xNDC = pProj.x * uScale / uAspect;
1009
+ const yNDC = pProj.y * uScale;
1010
+ const sX = (xNDC * 0.5 + 0.5) * w;
1011
+ const sY = (-yNDC * 0.5 + 0.5) * h;
1012
+ const dx = mX - sX;
1013
+ const dy = mY - sY;
1014
+ 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) {
1017
+ minLabelDist = d;
1018
+ closestLabel = item;
1019
+ }
1020
+ }
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 };
1033
+ }
1034
+ }
1035
+ return void 0;
1066
1036
  }
1067
- const onWheelFov = (ev) => {
1068
- ev.preventDefault();
1069
- const speed = (ev.ctrlKey ? 0.15 : 1) * (env.fovWheelSensitivity);
1070
- const maxFov = Math.min(env.maxFov, zoomOutLimitFov) ;
1071
- targetFov = THREE4.MathUtils.clamp(
1072
- targetFov + ev.deltaY * speed,
1073
- env.minFov,
1074
- maxFov
1075
- );
1076
- };
1077
- const onDblClick = (ev) => {
1078
- const node = pick(ev);
1079
- if (node) {
1080
- const mesh = meshById.get(node.id);
1081
- if (mesh) {
1082
- animateFocusTo(mesh);
1037
+ function onMouseDown(e) {
1038
+ state.lastMouseX = e.clientX;
1039
+ state.lastMouseY = e.clientY;
1040
+ if (currentConfig?.editable) {
1041
+ const hit = pick(e);
1042
+ if (hit) {
1043
+ state.dragMode = "node";
1044
+ state.draggedNodeId = hit.node.id;
1045
+ state.draggedDist = hit.point.length();
1046
+ document.body.style.cursor = "crosshair";
1047
+ if (hit.type === "star") {
1048
+ state.draggedStarIndex = hit.index ?? -1;
1049
+ state.draggedGroup = null;
1050
+ } else if (hit.type === "label") {
1051
+ const bookId = hit.node.id;
1052
+ const children = [];
1053
+ if (starPoints && starPoints.geometry.attributes.position) {
1054
+ const positions = starPoints.geometry.attributes.position.array;
1055
+ for (let i = 0; i < starIndexToId.length; i++) {
1056
+ const starId = starIndexToId[i];
1057
+ if (starId) {
1058
+ const starNode = nodeById.get(starId);
1059
+ 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]) });
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ state.draggedGroup = { labelInitialPos: hit.object.position.clone(), children };
1066
+ state.draggedStarIndex = -1;
1067
+ }
1083
1068
  return;
1084
1069
  }
1085
1070
  }
1086
- targetFov = env.defaultFov;
1087
- };
1088
- let focusAnimRaf = 0;
1089
- function cancelFocusAnim() {
1090
- if (focusAnimRaf) cancelAnimationFrame(focusAnimRaf);
1091
- focusAnimRaf = 0;
1092
- }
1093
- function getControlsAnglesSafe() {
1094
- const getAz = controls.getAzimuthalAngle?.bind(controls);
1095
- const getPol = controls.getPolarAngle?.bind(controls);
1096
- return {
1097
- azimuth: typeof getAz === "function" ? getAz() : 0,
1098
- polar: typeof getPol === "function" ? getPol() : Math.PI / 4
1099
- };
1071
+ state.dragMode = "camera";
1072
+ state.isDragging = true;
1073
+ state.velocityX = 0;
1074
+ state.velocityY = 0;
1075
+ document.body.style.cursor = "grabbing";
1100
1076
  }
1101
- function setControlsAnglesSafe(azimuth, polar) {
1102
- const setAz = controls.setAzimuthalAngle?.bind(controls);
1103
- const setPol = controls.setPolarAngle?.bind(controls);
1104
- if (typeof setAz === "function" && typeof setPol === "function") {
1105
- setAz(azimuth);
1106
- setPol(polar);
1107
- controls.update();
1108
- return;
1077
+ function onMouseMove(e) {
1078
+ const rect = renderer.domElement.getBoundingClientRect();
1079
+ const mX = e.clientX - rect.left;
1080
+ const mY = e.clientY - rect.top;
1081
+ mouseNDC.x = mX / rect.width * 2 - 1;
1082
+ mouseNDC.y = -(mY / rect.height) * 2 + 1;
1083
+ isMouseInWindow = true;
1084
+ if (state.dragMode === "node") {
1085
+ const worldDir = getMouseWorldVector(mX, mY, rect.width, rect.height);
1086
+ const newPos = worldDir.multiplyScalar(state.draggedDist);
1087
+ if (state.draggedStarIndex !== -1 && starPoints) {
1088
+ const idx = state.draggedStarIndex;
1089
+ const attr = starPoints.geometry.attributes.position;
1090
+ attr.setXYZ(idx, newPos.x, newPos.y, newPos.z);
1091
+ attr.needsUpdate = true;
1092
+ } else if (state.draggedGroup && state.draggedNodeId) {
1093
+ const group = state.draggedGroup;
1094
+ const item = dynamicLabels.find((l) => l.node.id === state.draggedNodeId);
1095
+ if (item) item.obj.position.copy(newPos);
1096
+ const vStart = group.labelInitialPos.clone().normalize();
1097
+ const vEnd = newPos.clone().normalize();
1098
+ const q = new THREE4.Quaternion().setFromUnitVectors(vStart, vEnd);
1099
+ if (starPoints && group.children.length > 0) {
1100
+ const attr = starPoints.geometry.attributes.position;
1101
+ const tempVec = new THREE4.Vector3();
1102
+ for (const child of group.children) {
1103
+ tempVec.copy(child.initialPos).applyQuaternion(q);
1104
+ attr.setXYZ(child.index, tempVec.x, tempVec.y, tempVec.z);
1105
+ }
1106
+ attr.needsUpdate = true;
1107
+ }
1108
+ }
1109
+ } else if (state.dragMode === "camera") {
1110
+ const deltaX = e.clientX - state.lastMouseX;
1111
+ const deltaY = e.clientY - state.lastMouseY;
1112
+ state.lastMouseX = e.clientX;
1113
+ state.lastMouseY = e.clientY;
1114
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
1115
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1116
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
1117
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
1118
+ state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
1119
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale;
1120
+ state.lon = state.targetLon;
1121
+ state.lat = state.targetLat;
1122
+ } else {
1123
+ const hit = pick(e);
1124
+ if (hit?.node.id !== handlers._lastHoverId) {
1125
+ handlers._lastHoverId = hit?.node.id;
1126
+ handlers.onHover?.(hit?.node);
1127
+ }
1128
+ document.body.style.cursor = hit ? currentConfig?.editable ? "crosshair" : "pointer" : "default";
1109
1129
  }
1110
- const dir = new THREE4.Vector3();
1111
- dir.setFromSphericalCoords(1, polar, azimuth);
1112
- const lookAt2 = dir.clone().multiplyScalar(10);
1113
- camera.lookAt(lookAt2);
1114
1130
  }
1115
- function aimAtWorldPoint(worldPoint) {
1116
- const dir = worldPoint.clone().normalize().negate();
1117
- const spherical = new THREE4.Spherical().setFromVector3(dir);
1118
- let targetPolar = spherical.phi;
1119
- let targetAz = spherical.theta;
1120
- targetPolar = THREE4.MathUtils.clamp(targetPolar, controls.minPolarAngle, controls.maxPolarAngle);
1121
- return { targetAz, targetPolar };
1122
- }
1123
- function easeInOutCubic(t) {
1124
- return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1131
+ function onMouseUp(e) {
1132
+ if (state.dragMode === "node") {
1133
+ const fullArr = getFullArrangement();
1134
+ handlers.onArrangementChange?.(fullArr);
1135
+ state.dragMode = "none";
1136
+ state.draggedNodeId = null;
1137
+ state.draggedStarIndex = -1;
1138
+ state.draggedGroup = null;
1139
+ document.body.style.cursor = "default";
1140
+ } else if (state.dragMode === "camera") {
1141
+ state.isDragging = false;
1142
+ state.dragMode = "none";
1143
+ document.body.style.cursor = "default";
1144
+ } else {
1145
+ const hit = pick(e);
1146
+ if (hit) handlers.onSelect?.(hit.node);
1147
+ }
1125
1148
  }
1126
- function animateFocusTo(target) {
1127
- cancelFocusAnim();
1128
- const { azimuth: startAz, polar: startPolar } = getControlsAnglesSafe();
1129
- const startFov = camera.fov;
1130
- const targetPos = target instanceof THREE4.Object3D ? target.getWorldPosition(new THREE4.Vector3()) : target;
1131
- const { targetAz, targetPolar } = aimAtWorldPoint(targetPos);
1132
- const endFov = THREE4.MathUtils.clamp(env.focusZoomFov, env.minFov, env.maxFov);
1133
- const start2 = performance.now();
1134
- const dur = Math.max(120, env.focusDurationMs);
1135
- const tick = (now) => {
1136
- const t = THREE4.MathUtils.clamp((now - start2) / dur, 0, 1);
1137
- const k = easeInOutCubic(t);
1138
- let dAz = targetAz - startAz;
1139
- dAz = (dAz + Math.PI) % (Math.PI * 2) - Math.PI;
1140
- const curAz = startAz + dAz * k;
1141
- const curPolar = THREE4.MathUtils.lerp(startPolar, targetPolar, k);
1142
- setControlsAnglesSafe(curAz, curPolar);
1143
- camera.fov = THREE4.MathUtils.lerp(startFov, endFov, k);
1144
- camera.updateProjectionMatrix();
1145
- if (t < 1) {
1146
- focusAnimRaf = requestAnimationFrame(tick);
1147
- } else {
1148
- focusAnimRaf = 0;
1149
- }
1150
- };
1151
- focusAnimRaf = requestAnimationFrame(tick);
1149
+ function onWheel(e) {
1150
+ e.preventDefault();
1151
+ const aspect = container.clientWidth / container.clientHeight;
1152
+ renderer.domElement.getBoundingClientRect();
1153
+ const vBefore = getMouseViewVector(state.fov, aspect);
1154
+ const zoomSpeed = 1e-3 * state.fov;
1155
+ state.fov += e.deltaY * zoomSpeed;
1156
+ state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
1157
+ updateUniforms();
1158
+ const vAfter = getMouseViewVector(state.fov, aspect);
1159
+ const quaternion = new THREE4.Quaternion().setFromUnitVectors(vAfter, vBefore);
1160
+ const y = Math.sin(state.lat);
1161
+ const r = Math.cos(state.lat);
1162
+ const x = r * Math.sin(state.lon);
1163
+ const z = -r * Math.cos(state.lon);
1164
+ const currentLook = new THREE4.Vector3(x, y, z);
1165
+ const camForward = currentLook.clone().normalize();
1166
+ 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);
1171
+ const qNew = qOld.clone().multiply(quaternion);
1172
+ const newForward = new THREE4.Vector3(0, 0, -1).applyQuaternion(qNew);
1173
+ state.lat = Math.asin(Math.max(-0.999, Math.min(0.999, newForward.y)));
1174
+ state.lon = Math.atan2(newForward.x, -newForward.z);
1175
+ const newUp = new THREE4.Vector3(0, 1, 0).applyQuaternion(qNew);
1176
+ camera.up.copy(newUp);
1177
+ if (e.deltaY > 0 && state.fov > ENGINE_CONFIG.zenithStartFov) {
1178
+ const range = ENGINE_CONFIG.maxFov - ENGINE_CONFIG.zenithStartFov;
1179
+ let t = (state.fov - ENGINE_CONFIG.zenithStartFov) / range;
1180
+ t = Math.max(0, Math.min(1, t));
1181
+ const bias = ENGINE_CONFIG.zenithStrength * t;
1182
+ const zenithLat = Math.PI / 2 - 1e-3;
1183
+ state.lat = mix(state.lat, zenithLat, bias);
1184
+ }
1185
+ state.targetLat = state.lat;
1186
+ state.targetLon = state.lon;
1152
1187
  }
1153
- function onPointerUp(ev) {
1154
- if (isDragging) return;
1155
- const node = pick(ev);
1156
- if (!node) return;
1157
- handlers.onSelect?.(node);
1188
+ function resize() {
1189
+ const w = container.clientWidth || 1;
1190
+ const h = container.clientHeight || 1;
1191
+ renderer.setSize(w, h, false);
1192
+ camera.aspect = w / h;
1193
+ updateUniforms();
1158
1194
  }
1159
1195
  function start() {
1160
1196
  if (running) return;
1161
1197
  running = true;
1162
1198
  resize();
1163
1199
  window.addEventListener("resize", resize);
1164
- renderer.domElement.addEventListener("pointermove", onPointerMove);
1165
- renderer.domElement.addEventListener("pointerdown", onPointerDown);
1166
- renderer.domElement.addEventListener("pointerup", onPointerUp);
1167
- renderer.domElement.addEventListener("wheel", onWheelFov, { passive: false });
1168
- renderer.domElement.addEventListener("dblclick", onDblClick);
1169
- controls.addEventListener("change", onChange);
1170
- lastFrameMs = performance.now();
1171
- const tick = () => {
1172
- raf = requestAnimationFrame(tick);
1173
- const now = performance.now();
1174
- const dt = Math.min(0.05, Math.max(0, (now - lastFrameMs) / 1e3));
1175
- lastFrameMs = now;
1176
- const lerpK = 1 - Math.pow(1 - (env.zoomLerp), dt * 60);
1177
- const prevFov = camera.fov;
1178
- camera.fov = THREE4.MathUtils.lerp(camera.fov, targetFov, lerpK);
1179
- if (Math.abs(camera.fov - prevFov) > 1e-4) camera.updateProjectionMatrix();
1180
- controls.rotateSpeed = camera.fov / (env.defaultFov);
1181
- {
1182
- backdropGroup.rotation.y += SIDEREAL_RATE * (env.timeScale) * dt;
1183
- }
1184
- const fovNorm = (env.maxFov - camera.fov) / Math.max(1e-6, env.maxFov - env.minFov);
1185
- const fovN = THREE4.MathUtils.clamp(fovNorm, 0, 1);
1186
- const overlayFade = THREE4.MathUtils.clamp(1 - fovN * 1.15, 0, 1);
1187
- const minZoomFov = 15;
1188
- const scaleFactor = THREE4.MathUtils.clamp(
1189
- 1 + (camera.fov - minZoomFov) * 0.05,
1190
- 0.85,
1191
- 6
1192
- );
1193
- const cameraDir = new THREE4.Vector3();
1194
- camera.getWorldDirection(cameraDir);
1195
- const objPos = new THREE4.Vector3();
1196
- const objDir = new THREE4.Vector3();
1197
- for (const item of dynamicObjects) {
1198
- item.obj.scale.copy(item.initialScale).multiplyScalar(scaleFactor);
1199
- if (item.type === "label") {
1200
- const sprite = item.obj;
1201
- sprite.getWorldPosition(objPos);
1202
- objDir.subVectors(objPos, camera.position).normalize();
1203
- const dot = cameraDir.dot(objDir);
1204
- const fullVisibleDot = 0.96;
1205
- const invisibleDot = 0.88;
1206
- let opacity = 0;
1207
- if (dot >= fullVisibleDot) {
1208
- opacity = 1;
1209
- } else if (dot > invisibleDot) {
1210
- opacity = (dot - invisibleDot) / (fullVisibleDot - invisibleDot);
1211
- }
1212
- const finalOpacity = opacity * overlayFade;
1213
- sprite.material.opacity = finalOpacity;
1214
- sprite.visible = finalOpacity > 0.01;
1215
- const bookId = nodeById.get(item.obj.userData.id)?.id;
1216
- if (bookId) {
1217
- const line = lineByBookId.get(bookId);
1218
- if (line) {
1219
- line.material.opacity = (0.05 + opacity * 0.45) * overlayFade;
1220
- }
1221
- }
1222
- }
1200
+ const el = renderer.domElement;
1201
+ el.addEventListener("mousedown", onMouseDown);
1202
+ window.addEventListener("mousemove", onMouseMove);
1203
+ window.addEventListener("mouseup", onMouseUp);
1204
+ el.addEventListener("wheel", onWheel, { passive: false });
1205
+ el.addEventListener("mouseenter", () => {
1206
+ isMouseInWindow = true;
1207
+ });
1208
+ el.addEventListener("mouseleave", () => {
1209
+ isMouseInWindow = false;
1210
+ });
1211
+ raf = requestAnimationFrame(tick);
1212
+ }
1213
+ function tick() {
1214
+ if (!running) return;
1215
+ raf = requestAnimationFrame(tick);
1216
+ if (!state.isDragging && isMouseInWindow) {
1217
+ 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;
1240
+ } 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;
1247
+ }
1248
+ } else if (!state.isDragging) {
1249
+ state.lon += state.velocityX;
1250
+ state.lat += state.velocityY;
1251
+ state.velocityX *= ENGINE_CONFIG.inertiaDamping;
1252
+ state.velocityY *= ENGINE_CONFIG.inertiaDamping;
1253
+ }
1254
+ state.lat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.lat));
1255
+ const y = Math.sin(state.lat);
1256
+ const r = Math.cos(state.lat);
1257
+ const x = r * Math.sin(state.lon);
1258
+ 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();
1261
+ camera.up.lerp(idealUp, ENGINE_CONFIG.horizonLockStrength);
1262
+ camera.up.normalize();
1263
+ camera.lookAt(target);
1264
+ 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;
1270
+ for (const item of dynamicLabels) {
1271
+ 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);
1287
+ item.obj.visible = uniforms.uAlpha.value > 0.01;
1223
1288
  }
1224
- controls.update();
1225
- renderer.render(scene, camera);
1226
- };
1227
- tick();
1289
+ }
1290
+ renderer.render(scene, camera);
1228
1291
  }
1229
1292
  function stop() {
1230
1293
  running = false;
1231
1294
  cancelAnimationFrame(raf);
1232
- cancelFocusAnim();
1233
1295
  window.removeEventListener("resize", resize);
1234
- renderer.domElement.removeEventListener("pointermove", onPointerMove);
1235
- renderer.domElement.removeEventListener("pointerdown", onPointerDown);
1236
- renderer.domElement.removeEventListener("pointerup", onPointerUp);
1237
- renderer.domElement.removeEventListener("wheel", onWheelFov);
1238
- renderer.domElement.removeEventListener("dblclick", onDblClick);
1239
- controls.removeEventListener("change", onChange);
1296
+ const el = renderer.domElement;
1297
+ el.removeEventListener("mousedown", onMouseDown);
1298
+ window.removeEventListener("mousemove", onMouseMove);
1299
+ window.removeEventListener("mouseup", onMouseUp);
1300
+ el.removeEventListener("wheel", onWheel);
1240
1301
  }
1241
1302
  function dispose() {
1242
1303
  stop();
1243
- clearRoot();
1244
- for (const child of [...groundGroup.children]) {
1245
- groundGroup.remove(child);
1246
- disposeObject(child);
1247
- }
1248
- for (const child of [...backdropGroup.children]) {
1249
- backdropGroup.remove(child);
1250
- disposeObject(child);
1251
- }
1252
- controls.dispose();
1253
1304
  renderer.dispose();
1254
1305
  renderer.domElement.remove();
1255
1306
  }
1256
1307
  return { setConfig, start, stop, dispose, setHandlers, getFullArrangement };
1257
1308
  }
1309
+ var ENGINE_CONFIG;
1258
1310
  var init_createEngine = __esm({
1259
1311
  "src/engine/createEngine.ts"() {
1260
1312
  init_layout();
1261
1313
  init_materials();
1314
+ ENGINE_CONFIG = {
1315
+ minFov: 10,
1316
+ maxFov: 165,
1317
+ defaultFov: 80,
1318
+ dragSpeed: 125e-5,
1319
+ inertiaDamping: 0.92,
1320
+ blendStart: 60,
1321
+ blendEnd: 165,
1322
+ zenithStartFov: 110,
1323
+ zenithStrength: 0.02,
1324
+ horizonLockStrength: 0.05,
1325
+ edgePanThreshold: 0.15,
1326
+ edgePanMaxSpeed: 0.02
1327
+ };
1262
1328
  }
1263
1329
  });
1264
1330
  var StarMap = forwardRef(