seqeyes-python 0.3.4__tar.gz → 0.3.6__tar.gz

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.
Files changed (20) hide show
  1. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/PKG-INFO +1 -1
  2. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/pyproject.toml +1 -1
  3. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/src/seqeyes/__init__.py +1 -1
  4. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/src/seqeyes/resources/pulseq-bundle.js +1 -1
  5. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/src/seqeyes/resources/webview-bundle.js +147 -47
  6. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/.gitignore +0 -0
  7. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/README.md +0 -0
  8. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/examples/demo.ipynb +0 -0
  9. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/examples/demo.py +0 -0
  10. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/src/seqeyes/_plot.py +0 -0
  11. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/src/seqeyes/_renderer.py +0 -0
  12. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/src/seqeyes/resources/viewer.html +0 -0
  13. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/conftest.py +0 -0
  14. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/debug_full.html +0 -0
  15. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/debug_minimal.html +0 -0
  16. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/debug_viewer.html +0 -0
  17. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/debug_viewer.py +0 -0
  18. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/integration_test.py +0 -0
  19. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/test_plot.py +0 -0
  20. {seqeyes_python-0.3.4 → seqeyes_python-0.3.6}/tests/test_renderer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: seqeyes-python
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq.
5
5
  Project-URL: Homepage, https://github.com/bughht/seqeyes_plugin
6
6
  Project-URL: Repository, https://github.com/bughht/seqeyes_plugin
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "seqeyes-python"
7
- version = "0.3.4"
7
+ version = "0.3.6"
8
8
  description = "Interactive Pulseq MRI sequence viewer for Jupyter — lightweight, beautiful, replaces seq.plot() in pypulseq."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -29,4 +29,4 @@ from seqeyes._plot import set, reset
29
29
  from seqeyes._renderer import SeqEyesViewer
30
30
 
31
31
  __all__ = ["set", "reset", "SeqEyesViewer"]
32
- __version__ = "0.3.4"
32
+ __version__ = "0.3.6"
@@ -97,7 +97,7 @@ var Pulseq = (() => {
97
97
  });
98
98
 
99
99
  // package.json
100
- var version = "0.3.4";
100
+ var version = "0.3.6";
101
101
 
102
102
  // src/pulseq/decompressor.ts
103
103
  function decompressShape(compressed, numSamples) {
@@ -2277,15 +2277,32 @@ function drawTriggerBlocks(start,end,vi,ch,colors,vs,ve){
2277
2277
  /* ══ kspace.js ══ */
2278
2278
  var kOpen=false, kView="3d";
2279
2279
  var kSpaceTrajectoryDrawCount=0,kSpaceOverlayDrawCount=0;
2280
- var kCx=0, kCy=0, kCz=0, kScl=1; // view center & zoom
2280
+ /**
2281
+ * Pan is a screen offset in CSS pixels, applied after rotation; the rotation
2282
+ * pivot is k = 0 and never moves.
2283
+ *
2284
+ * These used to be one world-space point that was both the pivot and whatever
2285
+ * sat at canvas centre, so panning relocated the pivot and a later rotation
2286
+ * swung the cloud around empty space beside it. Separating them costs the
2287
+ * ability to orbit an off-centre feature, which is recorded as a deliberate
2288
+ * trade in plans/todo/KSPACE_ROTATION_CENTER.md.
2289
+ */
2290
+ var kPanX=0, kPanY=0, kScl=1;
2281
2291
  var kAutoFit=true;
2282
2292
  var kRotX=-0.5, kRotY=0.7; // default 3D perspective
2283
2293
  var kDragging=false, kDragPrev=null, kDragBtn=0;
2294
+ /**
2295
+ * Pending coalesced drag redraw. A drag updates the rotation on every
2296
+ * mousemove but only needs one draw per displayed frame; the frames in between
2297
+ * are overwritten before anyone sees them. The inertial path is already
2298
+ * requestAnimationFrame-driven, so this only brings the drag into line with it.
2299
+ */
2300
+ var _kDragRaf=0;
2284
2301
  var kCanvas=document.getElementById("kc"), kCtx=kCanvas.getContext("2d");
2285
2302
  var kDotSize=2, kUnit="cyc"; // cyc=1/m, rad=rad/m
2286
2303
 
2287
2304
  // ── Smooth animation targets ──────────────────────────────────────────
2288
- var _tRotX=kRotX, _tRotY=kRotY, _tScl=kScl, _tCx=kCx, _tCy=kCy, _tCz=kCz;
2305
+ var _tRotX=kRotX, _tRotY=kRotY, _tScl=kScl, _tPanX=kPanX, _tPanY=kPanY;
2289
2306
  var _kAnimId=null;
2290
2307
  var _kEasing=0.12; // higher = snappier, lower = smoother (0.06–0.20)
2291
2308
 
@@ -2297,27 +2314,27 @@ function startKSpaceAnim() {
2297
2314
  if (Math.abs(_tRotX - kRotX) > eps) { kRotX += (_tRotX - kRotX) * _kEasing; changed = true; }
2298
2315
  if (Math.abs(_tRotY - kRotY) > eps) { kRotY += (_tRotY - kRotY) * _kEasing; changed = true; }
2299
2316
  if (Math.abs(_tScl - kScl) > 0.001){ kScl += (_tScl - kScl) * _kEasing; changed = true; }
2300
- if (Math.abs(_tCx - kCx) > 0.001){ kCx += (_tCx - kCx) * _kEasing; changed = true; }
2301
- if (Math.abs(_tCy - kCy) > 0.001){ kCy += (_tCy - kCy) * _kEasing; changed = true; }
2302
- if (Math.abs(_tCz - kCz) > 0.001){ kCz += (_tCz - kCz) * _kEasing; changed = true; }
2317
+ if (Math.abs(_tPanX - kPanX) > 0.01) { kPanX += (_tPanX - kPanX) * _kEasing; changed = true; }
2318
+ if (Math.abs(_tPanY - kPanY) > 0.01) { kPanY += (_tPanY - kPanY) * _kEasing; changed = true; }
2303
2319
  if (changed) {
2304
2320
  drawKsFast();
2305
2321
  _kAnimId = requestAnimationFrame(tick);
2306
2322
  } else {
2307
2323
  // Snap to exact targets
2308
- kRotX=_tRotX; kRotY=_tRotY; kScl=_tScl; kCx=_tCx; kCy=_tCy; kCz=_tCz;
2324
+ kRotX=_tRotX; kRotY=_tRotY; kScl=_tScl; kPanX=_tPanX; kPanY=_tPanY;
2309
2325
  _kAnimId = null;
2326
+ drawKsFast(); // no longer moving: redraw the complete cloud
2310
2327
  }
2311
2328
  }
2312
2329
  _kAnimId = requestAnimationFrame(tick);
2313
2330
  }
2314
2331
 
2315
- function setKSpaceTarget(rx, ry, s, cx, cy, cz, instant) {
2332
+ function setKSpaceTarget(rx, ry, s, panX, panY, instant) {
2316
2333
  if (instant) {
2317
- kRotX=_tRotX=rx; kRotY=_tRotY=ry; kScl=_tScl=s; kCx=_tCx=cx; kCy=_tCy=cy; kCz=_tCz=cz;
2334
+ kRotX=_tRotX=rx; kRotY=_tRotY=ry; kScl=_tScl=s; kPanX=_tPanX=panX; kPanY=_tPanY=panY;
2318
2335
  drawKs();
2319
2336
  } else {
2320
- _tRotX=rx; _tRotY=ry; _tScl=s; _tCx=cx; _tCy=cy; _tCz=cz;
2337
+ _tRotX=rx; _tRotY=ry; _tScl=s; _tPanX=panX; _tPanY=panY;
2321
2338
  startKSpaceAnim();
2322
2339
  }
2323
2340
  }
@@ -2371,7 +2388,7 @@ if(systemThemeQuery){
2371
2388
  ═══════════════════════════════════════════════════════════════════════ */
2372
2389
  var gl=null, glProgram=null, glBuf=null, glN=0;
2373
2390
  var glAttribPos=-1, glAttribTime=-1;
2374
- var glU_cy=-1,glU_sy=-1,glU_cx=-1,glU_sx=-1,glU_center=-1,glU_scale=-1;
2391
+ var glU_cy=-1,glU_sy=-1,glU_cx=-1,glU_sx=-1,glU_pan=-1,glU_scale=-1;
2375
2392
  var glU_halfRes=-1,glU_tMin=-1,glU_tMax=-1,glU_dot=-1,glU_color=-1;
2376
2393
 
2377
2394
  // ── Cached bounds (computed once when data is uploaded) ──────────────
@@ -2389,16 +2406,16 @@ function initWebGL(){
2389
2406
  gl.shaderSource(vs,'\
2390
2407
  attribute vec3 aPos; attribute float aTime;\
2391
2408
  uniform float uCy,uSy,uCx,uSx,uScale;\
2392
- uniform vec3 uCenter; uniform vec2 uHalfRes;\
2409
+ uniform vec2 uPan; uniform vec2 uHalfRes;\
2393
2410
  uniform float uTMin,uTMax,uDot;\
2394
2411
  varying float vVis;\
2395
2412
  void main(){\
2396
- float dx=aPos.x-uCenter.x,dy=aPos.y-uCenter.y,dz=aPos.z-uCenter.z;\
2413
+ float dx=aPos.x,dy=aPos.y,dz=aPos.z;\
2397
2414
  float rx=dx*uCy-dz*uSy;\
2398
2415
  float rz=dx*uSy+dz*uCy;\
2399
2416
  float ry=dy*uCx-rz*uSx;\
2400
- float sx=rx*uScale/uHalfRes.x;\
2401
- float sy=ry*uScale/uHalfRes.y;\
2417
+ float sx=(rx*uScale+uPan.x)/uHalfRes.x;\
2418
+ float sy=(ry*uScale-uPan.y)/uHalfRes.y;\
2402
2419
  vVis=(aTime>=uTMin&&aTime<=uTMax)?1.0:-1.0;\
2403
2420
  gl_Position=vec4(sx,sy,0.0,1.0);\
2404
2421
  gl_PointSize=vVis>0.0?uDot:0.0;\
@@ -2423,11 +2440,76 @@ function initWebGL(){
2423
2440
  glAttribTime=gl.getAttribLocation(glProgram,"aTime");
2424
2441
  glU_cy=gl.getUniformLocation(glProgram,"uCy");glU_sy=gl.getUniformLocation(glProgram,"uSy");
2425
2442
  glU_cx=gl.getUniformLocation(glProgram,"uCx");glU_sx=gl.getUniformLocation(glProgram,"uSx");
2426
- glU_center=gl.getUniformLocation(glProgram,"uCenter");glU_scale=gl.getUniformLocation(glProgram,"uScale");
2443
+ glU_pan=gl.getUniformLocation(glProgram,"uPan");glU_scale=gl.getUniformLocation(glProgram,"uScale");
2427
2444
  glU_halfRes=gl.getUniformLocation(glProgram,"uHalfRes");glU_dot=gl.getUniformLocation(glProgram,"uDot");
2428
2445
  glU_tMin=gl.getUniformLocation(glProgram,"uTMin");glU_tMax=gl.getUniformLocation(glProgram,"uTMax");
2429
2446
  glU_color=gl.getUniformLocation(glProgram,"uColor");
2430
- return true;
2447
+ return !!(kDragging||_kTouchActive||_kAnimId);
2448
+ }
2449
+
2450
+ /** Points to keep on screen while the camera is moving. */
2451
+ var KSPACE_MOVING_POINT_TARGET = 600000;
2452
+
2453
+ /**
2454
+ * Stride to draw the cloud with while the camera is moving.
2455
+ *
2456
+ * Window culling cannot help when the whole sequence is in view: every point
2457
+ * really is visible, and 6.4 million of them onto a few hundred thousand
2458
+ * pixels costs about a second a frame. While the camera moves, every k-th
2459
+ * point is drawn instead.
2460
+ *
2461
+ * This is a true subset — each drawn point is a real ADC k-space location, and
2462
+ * nothing is interpolated or invented. The moment the drag or the easing
2463
+ * settles the full set is redrawn, so the image actually being read is always
2464
+ * exact; only the motion is cheaper. A subset also needs no extra memory: the
2465
+ * buffer is interleaved x,y,z,t at 16 bytes, so widening the attribute stride
2466
+ * to 16k selects every k-th vertex with no CPU work and no second upload.
2467
+ *
2468
+ * Returns 1 below the target, so narrow views and modest sequences are drawn
2469
+ * exactly even in motion.
2470
+ */
2471
+ function kSpaceMovingStride(count){
2472
+ // The target is overridable so the settle-exactness guarantee can be tested
2473
+ // against a fixture small enough to ship; no repository fixture exceeds it.
2474
+ var target=(typeof window!=='undefined'&&window.__kMovingPointTarget>0)
2475
+ ?window.__kMovingPointTarget:KSPACE_MOVING_POINT_TARGET;
2476
+ if(!(count>target))return 1;
2477
+ return Math.ceil(count/target);
2478
+ }
2479
+
2480
+ /** True while a drag, touch gesture, or camera easing is in progress. */
2481
+ function kSpaceCameraMoving(){
2482
+ return !!(kDragging||_kTouchActive||_kAnimId);
2483
+ }
2484
+
2485
+ /**
2486
+ * First index and count of the ADC points whose time lies in `[vs, ve]`.
2487
+ *
2488
+ * `kAdcTime` ascends by construction — src/pulseq/kspace.ts fills it block by
2489
+ * block in sequence order — and `uploadKSpaceGPU` writes the GPU buffer in that
2490
+ * same order, so the visible window is one contiguous run of vertices.
2491
+ *
2492
+ * Drawing only that run is not a reduction. The vertex shader already rejects
2493
+ * everything outside the window with `gl_PointSize = 0` and a `discard`, so the
2494
+ * pixels are identical; the GPU simply stops transforming millions of points in
2495
+ * order to throw them away. A 1.1 ms view of a 16 s sequence was submitting
2496
+ * 3.38 million vertices to draw about 218 of them.
2497
+ *
2498
+ * The range is padded by one point on each side and the shader keeps its own
2499
+ * time test, so an off-by-one here cannot change what is drawn.
2500
+ *
2501
+ * Pure and parameterised on purpose: the standalone app runs its renderer in an
2502
+ * IIFE with its own state and calls this global. Do not copy it into
2503
+ * web/index.html — a copy there would shadow this one and diverge.
2504
+ */
2505
+ function kSpaceWindowRange(times,total,vs,ve){
2506
+ if(!times||!(total>0)||!(ve>=vs))return{first:0,count:0};
2507
+ var n=Math.min(total,times.length),lo=0,hi=n,mid;
2508
+ while(lo<hi){mid=(lo+hi)>>1;if(times[mid]<vs)lo=mid+1;else hi=mid;}
2509
+ var first=Math.max(0,lo-1);
2510
+ lo=first;hi=n;
2511
+ while(lo<hi){mid=(lo+hi)>>1;if(times[mid]<=ve)lo=mid+1;else hi=mid;}
2512
+ return{first:first,count:Math.max(0,Math.min(n,lo+1)-first)};
2431
2513
  }
2432
2514
 
2433
2515
  /* ── Upload ADC k‑space data to GPU (called after base64 decode) ────── */
@@ -2463,7 +2545,7 @@ document.getElementById("krst").onclick=function(){
2463
2545
  kView="3d";
2464
2546
  document.getElementById("kax").textContent="3D";
2465
2547
  var af=_kAutoFitVals();
2466
- setKSpaceTarget(-0.5, 0.7, af.scl, af.cx, af.cy, af.cz, false);
2548
+ setKSpaceTarget(-0.5, 0.7, af.scl, af.panX, af.panY, false);
2467
2549
  };
2468
2550
  // Camera presets: smoothly rotate to look straight down an axis
2469
2551
  document.getElementById("kax").onclick=function(){
@@ -2476,7 +2558,7 @@ document.getElementById("kax").onclick=function(){
2476
2558
  else{trx=-0.5; tr=0.7;} // 3d — default perspective
2477
2559
  document.getElementById("kax").textContent=kView.toUpperCase();
2478
2560
  var af=_kAutoFitVals();
2479
- setKSpaceTarget(trx, tr, af.scl, af.cx, af.cy, af.cz, false);
2561
+ setKSpaceTarget(trx, tr, af.scl, af.panX, af.panY, false);
2480
2562
  };
2481
2563
 
2482
2564
  function resizeKc(){
@@ -2597,9 +2679,13 @@ kCanvas.addEventListener("wheel",function(e){e.preventDefault();
2597
2679
  var r=kCanvas.getBoundingClientRect(),dpr=window.devicePixelRatio||1;
2598
2680
  var mx=(e.clientX-r.left)/dpr, my=(e.clientY-r.top)/dpr;
2599
2681
  var zf=e.deltaY<0?1.25:0.8, W=kCanvas.width/dpr, H=kCanvas.height/dpr;
2600
- var dz=(1-1/zf)*dpr/kScl;
2601
2682
  kAutoFit=false;
2602
- setKSpaceTarget(kRotX, kRotY, kScl*zf, kCx+(mx-W/2)*dz, kCy-(my-H/2)*dz, kCz, false);
2683
+ // Keep whatever is under the cursor under the cursor. With pan in screen
2684
+ // space this is the same arithmetic at every rotation; the previous form
2685
+ // added a screen delta straight to world coordinates and only aimed
2686
+ // correctly in the unrotated view.
2687
+ setKSpaceTarget(kRotX, kRotY, kScl*zf,
2688
+ kPanX+(mx-W/2-kPanX)*(1-zf), kPanY+(my-H/2-kPanY)*(1-zf), false);
2603
2689
  },{passive:false});
2604
2690
 
2605
2691
  window.addEventListener("mousemove",function(e){
@@ -2612,18 +2698,25 @@ window.addEventListener("mousemove",function(e){
2612
2698
  kRotY+=dx*0.008; kRotX-=dy*0.008;
2613
2699
  _tRotY=kRotY; _tRotX=kRotX; // sync targets
2614
2700
  }else{
2615
- // right/middle drag = instant pan
2616
- var cz=Math.cos(kRotY),sz=Math.sin(kRotY),cx=Math.cos(kRotX),sx=Math.sin(kRotX);
2617
- var dpr=window.devicePixelRatio||1;
2618
- dx/=(dpr*kScl); dy/=(dpr*kScl);
2619
- if(Math.abs(cz)>0.01){kCy+=dy/cx;kCx+=(-dx-sz*sx*(dy/cx))/cz;}
2620
- else{kCy+=dy/cx;kCz+=(dx-cz*sx*(dy/cx))/sz;}
2621
- _tCy=kCy; _tCx=kCx; _tCz=kCz; // sync targets
2701
+ // right/middle drag = instant pan. Pan is a screen offset now, so it
2702
+ // needs no inverse rotation and cannot move the rotation pivot.
2703
+ kPanX+=dx; kPanY+=dy;
2704
+ _tPanX=kPanX; _tPanY=kPanY; // sync targets
2622
2705
  kAutoFit=false;
2623
2706
  }
2624
- drawKsFast();
2707
+ scheduleKsDragDraw();
2708
+ });
2709
+ window.addEventListener("mouseup",function(){
2710
+ if(!kDragging)return;
2711
+ kDragging=false;kDragPrev=null;
2712
+ drawKsFast(); // settled: replace the moving subset with every point
2625
2713
  });
2626
- window.addEventListener("mouseup",function(){kDragging=false;kDragPrev=null;});
2714
+
2715
+ /** Draw at most once per frame, always from the latest rotation. */
2716
+ function scheduleKsDragDraw(){
2717
+ if(_kDragRaf)return;
2718
+ _kDragRaf=requestAnimationFrame(function(){_kDragRaf=0;drawKsFast();});
2719
+ }
2627
2720
 
2628
2721
  /* ── Touch: 3D k-space viewer ─────────────────────────────────────── */
2629
2722
  var _kTouchActive=false,_kTouchPrev=null,_kTouchPinch0=0,_kTouchMid=null,_kTouchBtn=0;
@@ -2663,18 +2756,15 @@ kCanvas.addEventListener("touchmove",function(e){
2663
2756
  var mid=getTouchMid(e.touches);
2664
2757
  var pdx=(mid.x-_kTouchPrev.x), pdy=(mid.y-_kTouchPrev.y);
2665
2758
  _kTouchPrev={x:mid.x,y:mid.y};
2666
- var cz=Math.cos(kRotY),sz=Math.sin(kRotY),cx=Math.cos(kRotX),sx=Math.sin(kRotX);
2667
- var dpr=window.devicePixelRatio||1;
2668
- pdx/=(dpr*kScl); pdy/=(dpr*kScl);
2669
- if(Math.abs(cz)>0.01){kCy+=pdy/cx;kCx+=(-pdx-sz*sx*(pdy/cx))/cz;}
2670
- else{kCy+=pdy/cx;kCz+=(pdx-cz*sx*(pdy/cx))/sz;}
2671
- _tCy=kCy; _tCx=kCx; _tCz=kCz; kAutoFit=false;
2759
+ kPanX+=pdx; kPanY+=pdy;
2760
+ _tPanX=kPanX; _tPanY=kPanY; kAutoFit=false;
2672
2761
  drawKsFast();
2673
2762
  }
2674
2763
  e.preventDefault();
2675
2764
  },{passive:false});
2676
2765
  kCanvas.addEventListener("touchend",function(e){
2677
2766
  _kTouchActive=false;_kTouchPrev=null;_kTouchPinch0=0;_kTouchMid=null;
2767
+ if(!e.touches.length)drawKsFast();
2678
2768
  if(e.touches.length===1){
2679
2769
  // Transition from 2‑finger to 1‑finger
2680
2770
  _kTouchActive=true;_kTouchBtn=0;
@@ -2683,6 +2773,7 @@ kCanvas.addEventListener("touchend",function(e){
2683
2773
  });
2684
2774
  kCanvas.addEventListener("touchcancel",function(){
2685
2775
  _kTouchActive=false;_kTouchPrev=null;_kTouchPinch0=0;_kTouchMid=null;
2776
+ drawKsFast();
2686
2777
  });
2687
2778
 
2688
2779
  /* ── Resize handle ───────────────────────────────────────────────────── */
@@ -2770,8 +2861,13 @@ function persistPanelSize(){
2770
2861
  function kNice(range){var ms=[1,2,5,10,20,50,100,200,500];for(var i=0;i<ms.length;i++){var b=Math.pow(10,Math.floor(Math.log10(range)));if(ms[i]*b>=range/4)return ms[i]*b;}return 1;}
2771
2862
 
2772
2863
  function _kAutoFitVals(){
2773
- var rng=_kBrng||1,W=kCanvas.width/(window.devicePixelRatio||1),H=kCanvas.height/(window.devicePixelRatio||1);
2774
- return{cx:(_kBxmin+_kBxmax)/2,cy:(_kBymin+_kBymax)/2,cz:(_kBzmin+_kBzmax)/2,scl:Math.min(W,H)/(rng*1.15)};
2864
+ var W=kCanvas.width/(window.devicePixelRatio||1),H=kCanvas.height/(window.devicePixelRatio||1);
2865
+ // The pivot is k = 0, so what has to fit is the furthest point from the
2866
+ // origin, not the width of the bounding box.
2867
+ var reach=Math.max(Math.abs(_kBxmin),Math.abs(_kBxmax),Math.abs(_kBymin),
2868
+ Math.abs(_kBymax),Math.abs(_kBzmin),Math.abs(_kBzmax));
2869
+ if(!(reach>0)||!isFinite(reach))reach=(_kBrng||1)/2;
2870
+ return{panX:0,panY:0,scl:Math.min(W,H)/(reach*2*1.15)};
2775
2871
  }
2776
2872
 
2777
2873
  /* ═══════════════════════════════════════════════════════════════════════
@@ -2842,7 +2938,7 @@ function drawKs_core(W,H,dpr){
2842
2938
  if(kAutoFit){
2843
2939
  kAutoFit=false;
2844
2940
  var af=_kAutoFitVals();
2845
- setKSpaceTarget(kRotX, kRotY, af.scl, af.cx, af.cy, af.cz, false);
2941
+ setKSpaceTarget(kRotX, kRotY, af.scl, af.panX, af.panY, false);
2846
2942
  }
2847
2943
 
2848
2944
  // ── Time window ──
@@ -2859,7 +2955,7 @@ function drawKs_core(W,H,dpr){
2859
2955
 
2860
2956
  gl.uniform1f(glU_cy,Math.cos(kRotY));gl.uniform1f(glU_sy,Math.sin(kRotY));
2861
2957
  gl.uniform1f(glU_cx,Math.cos(kRotX));gl.uniform1f(glU_sx,Math.sin(kRotX));
2862
- gl.uniform3f(glU_center,kCx,kCy,kCz);
2958
+ gl.uniform2f(glU_pan,kPanX*dpr,kPanY*dpr);
2863
2959
  gl.uniform1f(glU_scale,kScl);
2864
2960
  gl.uniform2f(glU_halfRes,hW,hH);
2865
2961
  gl.uniform1f(glU_tMin,vs);gl.uniform1f(glU_tMax,ve);
@@ -2869,14 +2965,19 @@ function drawKs_core(W,H,dpr){
2869
2965
  var rgb=parseCSSColor(ac);
2870
2966
  gl.uniform4f(glU_color,rgb[0],rgb[1],rgb[2],0.85);
2871
2967
 
2968
+ var win=kSpaceWindowRange(kAdcTime,glN,vs,ve);
2969
+ var stride=kSpaceCameraMoving()?kSpaceMovingStride(win.count):1;
2970
+
2872
2971
  gl.bindBuffer(gl.ARRAY_BUFFER,glBuf);
2873
2972
  gl.enableVertexAttribArray(glAttribPos);
2874
- gl.vertexAttribPointer(glAttribPos,3,gl.FLOAT,false,16,0);
2973
+ gl.vertexAttribPointer(glAttribPos,3,gl.FLOAT,false,16*stride,0);
2875
2974
  gl.enableVertexAttribArray(glAttribTime);
2876
- gl.vertexAttribPointer(glAttribTime,1,gl.FLOAT,false,16,12);
2975
+ gl.vertexAttribPointer(glAttribTime,1,gl.FLOAT,false,16*stride,12);
2877
2976
 
2878
2977
  kSpaceTrajectoryDrawCount++;
2879
- gl.drawArrays(gl.POINTS,0,glN);
2978
+ // Stride reindexes the buffer, so the window offset and count scale with it.
2979
+ var drawFirst=Math.floor(win.first/stride),drawCount=Math.floor(win.count/stride);
2980
+ if(drawCount>0)gl.drawArrays(gl.POINTS,drawFirst,drawCount);
2880
2981
  }
2881
2982
 
2882
2983
  drawKsOverlay(W,H,dpr,cs);
@@ -2900,11 +3001,10 @@ function drawKsOverlay(W,H,dpr,cs){
2900
3001
  var cz=Math.cos(kRotY),sz=Math.sin(kRotY),cxR=Math.cos(kRotX),sxR=Math.sin(kRotX);
2901
3002
  var invDpr=1/dpr;
2902
3003
  function proj(px,py,pz){
2903
- var dx2=px-kCx, dy2=py-kCy, dz2=pz-kCz;
2904
- var rx=dx2*cz-dz2*sz;
2905
- var rz2=dx2*sz+dz2*cz;
2906
- var ry=dy2*cxR-rz2*sxR;
2907
- return {x:W/2+rx*kScl*invDpr, y:H/2-ry*kScl*invDpr};
3004
+ var rx=px*cz-pz*sz;
3005
+ var rz2=px*sz+pz*cz;
3006
+ var ry=py*cxR-rz2*sxR;
3007
+ return {x:W/2+rx*kScl*invDpr+kPanX, y:H/2-ry*kScl*invDpr+kPanY};
2908
3008
  }
2909
3009
  var tick=kNice(rng3);
2910
3010
  function drawAxis3D(fx,fy,fz,label,col){
File without changes