@project-skymap/library 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -746,10 +746,14 @@ var init_projections = __esm({
746
746
  maxFov = 165;
747
747
  glslProjectionType = 2;
748
748
  /** FOV thresholds for blend transition (degrees) */
749
- blendStart = 40;
750
- blendEnd = 100;
749
+ blendStart;
750
+ blendEnd;
751
751
  /** Current blend factor, updated via setFov() */
752
752
  blend = 0;
753
+ constructor(blendStart = 40, blendEnd = 100) {
754
+ this.blendStart = blendStart;
755
+ this.blendEnd = blendEnd;
756
+ }
753
757
  /** Call this each frame / when FOV changes so forward/inverse stay in sync */
754
758
  setFov(fovDeg) {
755
759
  if (fovDeg <= this.blendStart) {
@@ -802,8 +806,7 @@ var init_projections = __esm({
802
806
  };
803
807
  exports.PROJECTIONS = {
804
808
  perspective: () => new PerspectiveProjection(),
805
- stereographic: () => new StereographicProjection(),
806
- blended: () => new BlendedProjection()
809
+ stereographic: () => new StereographicProjection()
807
810
  };
808
811
  }
809
812
  });
@@ -846,7 +849,8 @@ function createEngine({
846
849
  onSelect,
847
850
  onHover,
848
851
  onArrangementChange,
849
- onFovChange
852
+ onFovChange,
853
+ onLongPress
850
854
  }) {
851
855
  let hoveredBookId = null;
852
856
  let focusedBookId = null;
@@ -906,18 +910,36 @@ function createEngine({
906
910
  draggedStarIndex: -1,
907
911
  draggedDist: 2e3,
908
912
  draggedGroup: null,
909
- tempArrangement: {}
913
+ tempArrangement: {},
914
+ // Touch state
915
+ touchCount: 0,
916
+ touchStartTime: 0,
917
+ touchStartX: 0,
918
+ touchStartY: 0,
919
+ touchMoved: false,
920
+ pinchStartDistance: 0,
921
+ pinchStartFov: ENGINE_CONFIG.defaultFov,
922
+ pinchCenterX: 0,
923
+ pinchCenterY: 0,
924
+ // Double-tap detection
925
+ lastTapTime: 0,
926
+ lastTapX: 0,
927
+ lastTapY: 0,
928
+ // Long-press detection
929
+ longPressTimer: null,
930
+ longPressTriggered: false
910
931
  };
911
932
  const mouseNDC = new THREE5__namespace.Vector2();
912
933
  let isMouseInWindow = false;
934
+ let isTouchDevice = false;
913
935
  let edgeHoverStart = 0;
914
- let handlers = { onSelect, onHover, onArrangementChange, onFovChange };
936
+ let handlers = { onSelect, onHover, onArrangementChange, onFovChange, onLongPress };
915
937
  let currentConfig;
916
938
  const constellationLayer = new ConstellationArtworkLayer(scene);
917
939
  function mix(a, b, t) {
918
940
  return a * (1 - t) + b * t;
919
941
  }
920
- let currentProjection = exports.PROJECTIONS.blended();
942
+ let currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
921
943
  function syncProjectionState() {
922
944
  if (currentProjection instanceof BlendedProjection) {
923
945
  currentProjection.setFov(state.fov);
@@ -1955,9 +1977,13 @@ function createEngine({
1955
1977
  let lastAppliedLat = void 0;
1956
1978
  let lastBackdropCount = void 0;
1957
1979
  function setProjection(id) {
1958
- const factory = exports.PROJECTIONS[id];
1959
- if (!factory) return;
1960
- currentProjection = factory();
1980
+ if (id === "blended") {
1981
+ currentProjection = new BlendedProjection(ENGINE_CONFIG.blendStart, ENGINE_CONFIG.blendEnd);
1982
+ } else {
1983
+ const factory = exports.PROJECTIONS[id];
1984
+ if (!factory) return;
1985
+ currentProjection = factory();
1986
+ }
1961
1987
  updateUniforms();
1962
1988
  }
1963
1989
  function setConfig(cfg) {
@@ -2072,7 +2098,8 @@ function createEngine({
2072
2098
  const w = rect.width;
2073
2099
  const h = rect.height;
2074
2100
  let closestLabel = null;
2075
- let minLabelDist = 40;
2101
+ const LABEL_THRESHOLD = isTouchDevice ? 48 : 40;
2102
+ let minLabelDist = LABEL_THRESHOLD;
2076
2103
  for (const item of dynamicLabels) {
2077
2104
  if (!item.obj.visible) continue;
2078
2105
  if (isNodeFiltered(item.node)) continue;
@@ -2385,6 +2412,196 @@ function createEngine({
2385
2412
  state.targetLat = state.lat;
2386
2413
  state.targetLon = state.lon;
2387
2414
  }
2415
+ function getTouchDistance(t1, t2) {
2416
+ const dx = t1.clientX - t2.clientX;
2417
+ const dy = t1.clientY - t2.clientY;
2418
+ return Math.sqrt(dx * dx + dy * dy);
2419
+ }
2420
+ function getTouchCenter(t1, t2) {
2421
+ return {
2422
+ x: (t1.clientX + t2.clientX) / 2,
2423
+ y: (t1.clientY + t2.clientY) / 2
2424
+ };
2425
+ }
2426
+ function onTouchStart(e) {
2427
+ e.preventDefault();
2428
+ isTouchDevice = true;
2429
+ if (state.longPressTimer) {
2430
+ clearTimeout(state.longPressTimer);
2431
+ state.longPressTimer = null;
2432
+ }
2433
+ state.longPressTriggered = false;
2434
+ const touches = e.touches;
2435
+ state.touchCount = touches.length;
2436
+ if (touches.length === 1) {
2437
+ const touch = touches[0];
2438
+ state.touchStartTime = performance.now();
2439
+ state.touchStartX = touch.clientX;
2440
+ state.touchStartY = touch.clientY;
2441
+ state.touchMoved = false;
2442
+ state.lastMouseX = touch.clientX;
2443
+ state.lastMouseY = touch.clientY;
2444
+ flyToActive = false;
2445
+ state.dragMode = "camera";
2446
+ state.isDragging = true;
2447
+ state.velocityX = 0;
2448
+ state.velocityY = 0;
2449
+ state.longPressTimer = setTimeout(() => {
2450
+ if (!state.touchMoved && state.touchCount === 1) {
2451
+ state.longPressTriggered = true;
2452
+ const rect = renderer.domElement.getBoundingClientRect();
2453
+ const mX = state.touchStartX - rect.left;
2454
+ const mY = state.touchStartY - rect.top;
2455
+ mouseNDC.x = mX / rect.width * 2 - 1;
2456
+ mouseNDC.y = -(mY / rect.height) * 2 + 1;
2457
+ const syntheticEvent = {
2458
+ clientX: state.touchStartX,
2459
+ clientY: state.touchStartY
2460
+ };
2461
+ const hit = pick(syntheticEvent);
2462
+ handlers.onLongPress?.(hit?.node ?? null, state.touchStartX, state.touchStartY);
2463
+ }
2464
+ }, ENGINE_CONFIG.longPressDelay);
2465
+ } else if (touches.length === 2) {
2466
+ const t0 = touches[0];
2467
+ const t1 = touches[1];
2468
+ state.pinchStartDistance = getTouchDistance(t0, t1);
2469
+ state.pinchStartFov = state.fov;
2470
+ const center = getTouchCenter(t0, t1);
2471
+ state.pinchCenterX = center.x;
2472
+ state.pinchCenterY = center.y;
2473
+ state.lastMouseX = center.x;
2474
+ state.lastMouseY = center.y;
2475
+ state.touchMoved = true;
2476
+ }
2477
+ }
2478
+ function onTouchMove(e) {
2479
+ e.preventDefault();
2480
+ const touches = e.touches;
2481
+ if (touches.length === 1 && state.dragMode === "camera") {
2482
+ const touch = touches[0];
2483
+ const deltaX = touch.clientX - state.lastMouseX;
2484
+ const deltaY = touch.clientY - state.lastMouseY;
2485
+ state.lastMouseX = touch.clientX;
2486
+ state.lastMouseY = touch.clientY;
2487
+ const totalDx = touch.clientX - state.touchStartX;
2488
+ const totalDy = touch.clientY - state.touchStartY;
2489
+ if (Math.sqrt(totalDx * totalDx + totalDy * totalDy) > ENGINE_CONFIG.tapMaxDistance) {
2490
+ state.touchMoved = true;
2491
+ if (state.longPressTimer) {
2492
+ clearTimeout(state.longPressTimer);
2493
+ state.longPressTimer = null;
2494
+ }
2495
+ }
2496
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2497
+ const rotLock = Math.max(0, Math.min(1, (state.fov - 100) / (ENGINE_CONFIG.maxFov - 100)));
2498
+ const latFactor = 1 - rotLock * rotLock;
2499
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2500
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2501
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2502
+ state.velocityX = deltaX * ENGINE_CONFIG.dragSpeed * speedScale;
2503
+ state.velocityY = deltaY * ENGINE_CONFIG.dragSpeed * speedScale * latFactor;
2504
+ state.lon = state.targetLon;
2505
+ state.lat = state.targetLat;
2506
+ } else if (touches.length === 2) {
2507
+ const t0 = touches[0];
2508
+ const t1 = touches[1];
2509
+ const newDistance = getTouchDistance(t0, t1);
2510
+ const scale = newDistance / state.pinchStartDistance;
2511
+ state.fov = state.pinchStartFov / scale;
2512
+ state.fov = Math.max(ENGINE_CONFIG.minFov, Math.min(ENGINE_CONFIG.maxFov, state.fov));
2513
+ handlers.onFovChange?.(state.fov);
2514
+ const center = getTouchCenter(t0, t1);
2515
+ const deltaX = center.x - state.lastMouseX;
2516
+ const deltaY = center.y - state.lastMouseY;
2517
+ state.lastMouseX = center.x;
2518
+ state.lastMouseY = center.y;
2519
+ const speedScale = state.fov / ENGINE_CONFIG.defaultFov;
2520
+ state.targetLon += deltaX * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2521
+ state.targetLat += deltaY * ENGINE_CONFIG.dragSpeed * speedScale * 0.5;
2522
+ state.targetLat = Math.max(-Math.PI / 2 + 0.01, Math.min(Math.PI / 2 - 0.01, state.targetLat));
2523
+ state.lon = state.targetLon;
2524
+ state.lat = state.targetLat;
2525
+ }
2526
+ }
2527
+ function onTouchEnd(e) {
2528
+ e.preventDefault();
2529
+ if (state.longPressTimer) {
2530
+ clearTimeout(state.longPressTimer);
2531
+ state.longPressTimer = null;
2532
+ }
2533
+ const remainingTouches = e.touches.length;
2534
+ if (remainingTouches === 0) {
2535
+ const now = performance.now();
2536
+ const duration = now - state.touchStartTime;
2537
+ const wasTap = !state.touchMoved && duration < ENGINE_CONFIG.tapMaxDuration && !state.longPressTriggered;
2538
+ if (wasTap) {
2539
+ const timeSinceLastTap = now - state.lastTapTime;
2540
+ const distFromLastTap = Math.sqrt(
2541
+ Math.pow(state.touchStartX - state.lastTapX, 2) + Math.pow(state.touchStartY - state.lastTapY, 2)
2542
+ );
2543
+ const isDoubleTap = timeSinceLastTap < ENGINE_CONFIG.doubleTapMaxDelay && distFromLastTap < ENGINE_CONFIG.doubleTapMaxDistance;
2544
+ const rect = renderer.domElement.getBoundingClientRect();
2545
+ const mX = state.touchStartX - rect.left;
2546
+ const mY = state.touchStartY - rect.top;
2547
+ mouseNDC.x = mX / rect.width * 2 - 1;
2548
+ mouseNDC.y = -(mY / rect.height) * 2 + 1;
2549
+ const syntheticEvent = {
2550
+ clientX: state.touchStartX,
2551
+ clientY: state.touchStartY
2552
+ };
2553
+ const hit = pick(syntheticEvent);
2554
+ if (isDoubleTap) {
2555
+ if (hit) {
2556
+ flyTo(hit.node.id, ENGINE_CONFIG.minFov);
2557
+ handlers.onSelect?.(hit.node);
2558
+ }
2559
+ state.lastTapTime = 0;
2560
+ state.lastTapX = 0;
2561
+ state.lastTapY = 0;
2562
+ } else {
2563
+ if (hit) {
2564
+ handlers.onSelect?.(hit.node);
2565
+ constellationLayer.setFocused(hit.node.id);
2566
+ if (hit.node.level === 2) setFocusedBook(hit.node.id);
2567
+ else if (hit.node.level === 3 && hit.node.parent) setFocusedBook(hit.node.parent);
2568
+ } else {
2569
+ setFocusedBook(null);
2570
+ }
2571
+ state.lastTapTime = now;
2572
+ state.lastTapX = state.touchStartX;
2573
+ state.lastTapY = state.touchStartY;
2574
+ }
2575
+ }
2576
+ state.isDragging = false;
2577
+ state.dragMode = "none";
2578
+ state.touchCount = 0;
2579
+ } else if (remainingTouches === 1) {
2580
+ const touch = e.touches[0];
2581
+ state.lastMouseX = touch.clientX;
2582
+ state.lastMouseY = touch.clientY;
2583
+ state.touchCount = 1;
2584
+ state.dragMode = "camera";
2585
+ state.isDragging = true;
2586
+ state.velocityX = 0;
2587
+ state.velocityY = 0;
2588
+ }
2589
+ }
2590
+ function onTouchCancel(e) {
2591
+ e.preventDefault();
2592
+ if (state.longPressTimer) {
2593
+ clearTimeout(state.longPressTimer);
2594
+ state.longPressTimer = null;
2595
+ }
2596
+ state.isDragging = false;
2597
+ state.dragMode = "none";
2598
+ state.touchCount = 0;
2599
+ state.velocityX = 0;
2600
+ state.velocityY = 0;
2601
+ }
2602
+ function onGesturePrevent(e) {
2603
+ e.preventDefault();
2604
+ }
2388
2605
  function resize() {
2389
2606
  const w = container.clientWidth || 1;
2390
2607
  const h = container.clientHeight || 1;
@@ -2407,6 +2624,13 @@ function createEngine({
2407
2624
  });
2408
2625
  el.addEventListener("mouseleave", onWindowBlur);
2409
2626
  window.addEventListener("blur", onWindowBlur);
2627
+ el.addEventListener("touchstart", onTouchStart, { passive: false });
2628
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
2629
+ el.addEventListener("touchend", onTouchEnd, { passive: false });
2630
+ el.addEventListener("touchcancel", onTouchCancel, { passive: false });
2631
+ el.addEventListener("gesturestart", onGesturePrevent, { passive: false });
2632
+ el.addEventListener("gesturechange", onGesturePrevent, { passive: false });
2633
+ el.addEventListener("gestureend", onGesturePrevent, { passive: false });
2410
2634
  raf = requestAnimationFrame(tick);
2411
2635
  }
2412
2636
  function tick() {
@@ -2448,7 +2672,7 @@ function createEngine({
2448
2672
  }
2449
2673
  let panX = 0;
2450
2674
  let panY = 0;
2451
- if (!state.isDragging && isMouseInWindow && !currentConfig?.editable) {
2675
+ if (!state.isDragging && isMouseInWindow && !currentConfig?.editable && !isTouchDevice) {
2452
2676
  const t = ENGINE_CONFIG.edgePanThreshold;
2453
2677
  const inZoneX = mouseNDC.x < -1 + t || mouseNDC.x > 1 - t;
2454
2678
  const inZoneY = mouseNDC.y < -1 + t || mouseNDC.y > 1 - t;
@@ -2501,8 +2725,9 @@ function createEngine({
2501
2725
  } else if (!state.isDragging && !flyToActive) {
2502
2726
  state.lon += state.velocityX;
2503
2727
  state.lat += state.velocityY;
2504
- state.velocityX *= ENGINE_CONFIG.inertiaDamping;
2505
- state.velocityY *= ENGINE_CONFIG.inertiaDamping;
2728
+ const damping = isTouchDevice ? ENGINE_CONFIG.touchInertiaDamping : ENGINE_CONFIG.inertiaDamping;
2729
+ state.velocityX *= damping;
2730
+ state.velocityY *= damping;
2506
2731
  if (Math.abs(state.velocityX) < 1e-6) state.velocityX = 0;
2507
2732
  if (Math.abs(state.velocityY) < 1e-6) state.velocityY = 0;
2508
2733
  }
@@ -2674,6 +2899,13 @@ function createEngine({
2674
2899
  el.removeEventListener("wheel", onWheel);
2675
2900
  el.removeEventListener("mouseleave", onWindowBlur);
2676
2901
  window.removeEventListener("blur", onWindowBlur);
2902
+ el.removeEventListener("touchstart", onTouchStart);
2903
+ el.removeEventListener("touchmove", onTouchMove);
2904
+ el.removeEventListener("touchend", onTouchEnd);
2905
+ el.removeEventListener("touchcancel", onTouchCancel);
2906
+ el.removeEventListener("gesturestart", onGesturePrevent);
2907
+ el.removeEventListener("gesturechange", onGesturePrevent);
2908
+ el.removeEventListener("gestureend", onGesturePrevent);
2677
2909
  }
2678
2910
  function dispose() {
2679
2911
  stop();
@@ -2728,7 +2960,7 @@ var init_createEngine = __esm({
2728
2960
  init_projections();
2729
2961
  init_fader();
2730
2962
  ENGINE_CONFIG = {
2731
- minFov: 10,
2963
+ minFov: 1,
2732
2964
  maxFov: 135,
2733
2965
  defaultFov: 50,
2734
2966
  dragSpeed: 125e-5,
@@ -2740,7 +2972,20 @@ var init_createEngine = __esm({
2740
2972
  horizonLockStrength: 0.05,
2741
2973
  edgePanThreshold: 0.15,
2742
2974
  edgePanMaxSpeed: 0.02,
2743
- edgePanDelay: 250
2975
+ edgePanDelay: 250,
2976
+ // Touch-specific
2977
+ touchInertiaDamping: 0.85,
2978
+ // Snappier than mouse (0.92)
2979
+ tapMaxDuration: 300,
2980
+ // ms
2981
+ tapMaxDistance: 10,
2982
+ // px
2983
+ doubleTapMaxDelay: 300,
2984
+ // ms between taps
2985
+ doubleTapMaxDistance: 30,
2986
+ // px between tap locations
2987
+ longPressDelay: 500
2988
+ // ms to trigger long-press
2744
2989
  };
2745
2990
  ORDER_REVEAL_CONFIG = {
2746
2991
  globalDim: 0.85,
@@ -2751,7 +2996,7 @@ var init_createEngine = __esm({
2751
2996
  }
2752
2997
  });
2753
2998
  var StarMap = react.forwardRef(
2754
- ({ config, className, onSelect, onHover, onArrangementChange, onFovChange }, ref) => {
2999
+ ({ config, className, onSelect, onHover, onArrangementChange, onFovChange, onLongPress }, ref) => {
2755
3000
  const containerRef = react.useRef(null);
2756
3001
  const engineRef = react.useRef(null);
2757
3002
  react.useImperativeHandle(ref, () => ({
@@ -2774,7 +3019,8 @@ var StarMap = react.forwardRef(
2774
3019
  onSelect,
2775
3020
  onHover,
2776
3021
  onArrangementChange,
2777
- onFovChange
3022
+ onFovChange,
3023
+ onLongPress
2778
3024
  });
2779
3025
  engineRef.current.setConfig(config);
2780
3026
  engineRef.current.start();
@@ -2790,8 +3036,8 @@ var StarMap = react.forwardRef(
2790
3036
  engineRef.current?.setConfig?.(config);
2791
3037
  }, [config]);
2792
3038
  react.useEffect(() => {
2793
- engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange, onFovChange });
2794
- }, [onSelect, onHover, onArrangementChange, onFovChange]);
3039
+ engineRef.current?.setHandlers?.({ onSelect, onHover, onArrangementChange, onFovChange, onLongPress });
3040
+ }, [onSelect, onHover, onArrangementChange, onFovChange, onLongPress]);
2795
3041
  return /* @__PURE__ */ jsxRuntime.jsx("div", { ref: containerRef, className, style: { width: "100%", height: "100%" } });
2796
3042
  }
2797
3043
  );