@vitessce/neuroglancer 3.6.18 → 3.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.
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable no-unused-vars */
2
- import React, { useCallback, useMemo } from 'react';
2
+ import React, { useCallback, useMemo, useRef, useEffect, useState } from 'react';
3
3
  import {
4
4
  TitleInfo,
5
5
  useCoordination,
@@ -14,53 +14,37 @@ import {
14
14
  COMPONENT_COORDINATION_TYPES,
15
15
  } from '@vitessce/constants-internal';
16
16
  import { mergeObsSets, getCellColors, setObsSelection } from '@vitessce/sets-utils';
17
- import { Neuroglancer } from './Neuroglancer.js';
17
+ import { NeuroglancerComp } from './Neuroglancer.js';
18
18
  import { useStyles } from './styles.js';
19
+ import {
20
+ quaternionToEuler,
21
+ eulerToQuaternion,
22
+ valueGreaterThanEpsilon,
23
+ nearEq,
24
+ makeVitNgZoomCalibrator,
25
+ conjQuat,
26
+ multiplyQuat,
27
+ rad2deg,
28
+ deg2rad,
29
+ Q_Y_UP,
30
+ } from './utils.js';
19
31
 
20
- const NEUROGLANCER_ZOOM_BASIS = 16;
21
-
22
- function mapVitessceToNeuroglancer(zoom) {
23
- return NEUROGLANCER_ZOOM_BASIS * (2 ** -zoom);
24
- }
25
-
26
- function mapNeuroglancerToVitessce(projectionScale) {
27
- return -Math.log2(projectionScale / NEUROGLANCER_ZOOM_BASIS);
28
- }
29
-
30
- function quaternionToEuler([x, y, z, w]) {
31
- // X-axis rotation (Roll)
32
- const thetaX = Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y));
33
-
34
- // Y-axis rotation (Pitch)
35
- const sinp = 2 * (w * y - z * x);
36
- const thetaY = Math.abs(sinp) >= 1 ? Math.sign(sinp) * (Math.PI / 2) : Math.asin(sinp);
37
-
38
- // Convert to degrees as Vitessce expects degrees?
39
- return [thetaX * (180 / Math.PI), thetaY * (180 / Math.PI)];
40
- }
41
-
42
-
43
- function eulerToQuaternion(thetaX, thetaY) {
44
- // Convert Euler angles (X, Y rotations) to quaternion
45
- const halfThetaX = thetaX / 2;
46
- const halfThetaY = thetaY / 2;
47
-
48
- const sinX = Math.sin(halfThetaX);
49
- const cosX = Math.cos(halfThetaX);
50
- const sinY = Math.sin(halfThetaY);
51
- const cosY = Math.cos(halfThetaY);
32
+ const VITESSCE_INTERACTION_DELAY = 50;
33
+ const INIT_VIT_ZOOM = -3.6;
34
+ const ZOOM_EPS = 1e-2;
35
+ const ROTATION_EPS = 1e-3;
36
+ const TARGET_EPS = 0.5;
37
+ const NG_ROT_COOLDOWN_MS = 120;
52
38
 
53
- return [
54
- sinX * cosY,
55
- cosX * sinY,
56
- sinX * sinY,
57
- cosX * cosY,
58
- ];
59
- }
39
+ const LAST_INTERACTION_SOURCE = {
40
+ vitessce: 'vitessce',
41
+ neuroglancer: 'neuroglancer',
42
+ };
60
43
 
61
- function normalizeQuaternion(q) {
62
- const length = Math.sqrt((q[0] ** 2) + (q[1] ** 2) + (q[2] ** 2) + (q[3] ** 2));
63
- return q.map(value => value / length);
44
+ function rgbToHex(rgb) {
45
+ return (typeof rgb === 'string'
46
+ ? rgb
47
+ : `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`);
64
48
  }
65
49
 
66
50
  export function NeuroglancerSubscriber(props) {
@@ -86,13 +70,13 @@ export function NeuroglancerSubscriber(props) {
86
70
  spatialTargetY,
87
71
  spatialRotationX,
88
72
  spatialRotationY,
89
- // spatialRotationZ,
90
- // spatialRotationOrbit,
91
- // spatialOrbitAxis,
73
+ spatialRotationZ,
74
+ spatialRotationOrbit,
75
+ // spatialOrbitAxis, // always along Y-axis - not used in conversion
92
76
  embeddingType: mapping,
77
+ obsSetColor: cellSetColor,
93
78
  obsSetSelection: cellSetSelection,
94
79
  additionalObsSets: additionalCellSets,
95
- obsSetColor: cellSetColor,
96
80
  }, {
97
81
  setAdditionalObsSets: setAdditionalCellSets,
98
82
  setObsSetColor: setCellSetColor,
@@ -102,13 +86,18 @@ export function NeuroglancerSubscriber(props) {
102
86
  setSpatialTargetX: setTargetX,
103
87
  setSpatialTargetY: setTargetY,
104
88
  setSpatialRotationX: setRotationX,
105
- setSpatialRotationY: setRotationY,
89
+ // setSpatialRotationY: setRotationY,
106
90
  // setSpatialRotationZ: setRotationZ,
107
- // setSpatialRotationOrbit: setRotationOrbit,
108
-
91
+ setSpatialRotationOrbit: setRotationOrbit,
109
92
  setSpatialZoom: setZoom,
110
93
  }] = useCoordination(COMPONENT_COORDINATION_TYPES[ViewType.NEUROGLANCER], coordinationScopes);
111
94
 
95
+
96
+ const latestViewerStateRef = useRef(initialViewerState);
97
+ const initialRotationPushedRef = useRef(false);
98
+
99
+ // console.log("NG Subs Render orbit", spatialRotationX, spatialRotationY, spatialRotationOrbit);
100
+
112
101
  const { classes } = useStyles();
113
102
 
114
103
  const [{ obsSets: cellSets }] = useObsSetsData(
@@ -123,23 +112,174 @@ export function NeuroglancerSubscriber(props) {
123
112
  { obsType, embeddingType: mapping },
124
113
  );
125
114
 
115
+ const ngRotPushAtRef = useRef(0);
116
+ const lastInteractionSource = useRef(null);
117
+ const applyNgUpdateTimeoutRef = useRef(null);
118
+ const lastNgPushOrientationRef = useRef(null);
119
+ const initialRenderCalibratorRef = useRef(null);
120
+ const translationOffsetRef = useRef([0, 0, 0]);
121
+ const zoomRafRef = useRef(null);
122
+ const lastNgQuatRef = useRef([0, 0, 0, 1]);
123
+ const lastNgScaleRef = useRef(null);
124
+ const lastVitessceRotationRef = useRef({
125
+ x: spatialRotationX,
126
+ y: spatialRotationY,
127
+ z: spatialRotationZ,
128
+ orbit: spatialRotationOrbit,
129
+ });
130
+
131
+ // Track the last coord values we saw, and only mark "vitessce"
132
+ // when *those* actually change. This prevents cell set renders
133
+ // from spoofing the source.
134
+ const prevCoordsRef = useRef({
135
+ zoom: spatialZoom,
136
+ rx: spatialRotationX,
137
+ ry: spatialRotationY,
138
+ rz: spatialRotationZ,
139
+ orbit: spatialRotationOrbit,
140
+ tx: spatialTargetX,
141
+ ty: spatialTargetY,
142
+ });
143
+
144
+ const mergedCellSets = useMemo(() => mergeObsSets(
145
+ cellSets, additionalCellSets,
146
+ ), [cellSets, additionalCellSets]);
147
+
148
+ const cellColors = useMemo(() => getCellColors({
149
+ cellSets: mergedCellSets,
150
+ cellSetSelection,
151
+ cellSetColor,
152
+ obsIndex,
153
+ theme,
154
+ }), [mergedCellSets, theme,
155
+ cellSetColor, cellSetSelection, obsIndex]);
156
+
157
+ /*
158
+ * handleStateUpdate - Interactions from NG to Vitessce are pushed here
159
+ */
126
160
  const handleStateUpdate = useCallback((newState) => {
161
+ lastInteractionSource.current = LAST_INTERACTION_SOURCE.neuroglancer;
127
162
  const { projectionScale, projectionOrientation, position } = newState;
128
- setZoom(mapNeuroglancerToVitessce(projectionScale));
129
- const vitessceEularMapping = quaternionToEuler(projectionOrientation);
130
163
 
131
- // TODO: support z rotation on SpatialView?
132
- setRotationX(vitessceEularMapping[0]);
133
- setRotationY(vitessceEularMapping[1]);
164
+ // Set the views on first mount
165
+ if (!initialRenderCalibratorRef.current) {
166
+ // wait for a real scale
167
+ if (!Number.isFinite(projectionScale) || projectionScale <= 0) return;
168
+
169
+ // anchor to current Vitessce zoom
170
+ const zRef = Number.isFinite(spatialZoom) ? spatialZoom : 0;
171
+ initialRenderCalibratorRef.current = makeVitNgZoomCalibrator(projectionScale, zRef);
172
+
173
+ const [px = 0, py = 0, pz = 0] = Array.isArray(position) ? position : [0, 0, 0];
174
+ const tX = Number.isFinite(spatialTargetX) ? spatialTargetX : 0;
175
+ const tY = Number.isFinite(spatialTargetY) ? spatialTargetY : 0;
176
+ // TODO: translation off in the first render - turn pz to 0 if z-axis needs to be avoided
177
+ translationOffsetRef.current = [px - tX, py - tY, pz];
178
+ // console.log(" translationOffsetRef.current", translationOffsetRef.current)
179
+ const syncedZoom = initialRenderCalibratorRef.current.vitToNgZoom(INIT_VIT_ZOOM);
180
+ latestViewerStateRef.current = {
181
+ ...latestViewerStateRef.current,
182
+ projectionScale: syncedZoom,
183
+ };
184
+
185
+ if (!Number.isFinite(spatialZoom) || Math.abs(spatialZoom - INIT_VIT_ZOOM) > ZOOM_EPS) {
186
+ setZoom(INIT_VIT_ZOOM);
187
+ }
188
+ return;
189
+ }
190
+
191
+ // ZOOM (NG → Vitessce) — do this only after calibrator exists
192
+ if (Number.isFinite(projectionScale) && projectionScale > 0) {
193
+ const vitZoomFromNg = initialRenderCalibratorRef.current.ngToVitZoom(projectionScale);
194
+ const scaleChanged = lastNgScaleRef.current == null
195
+ || (Math.abs(projectionScale - lastNgScaleRef.current)
196
+ > 1e-6 * Math.max(1, projectionScale));
197
+ if (scaleChanged && Number.isFinite(vitZoomFromNg)
198
+ && Math.abs(vitZoomFromNg - (spatialZoom ?? 0)) > ZOOM_EPS) {
199
+ if (zoomRafRef.current) cancelAnimationFrame(zoomRafRef.current);
200
+ zoomRafRef.current = requestAnimationFrame(() => {
201
+ setZoom(vitZoomFromNg);
202
+ zoomRafRef.current = null;
203
+ });
204
+ }
205
+ // remember last NG scale
206
+ lastNgScaleRef.current = projectionScale;
207
+ }
208
+
209
+ // TRANSLATION
210
+ if (Array.isArray(position) && position.length >= 2) {
211
+ const [px, py] = position;
212
+ const [ox, oy] = translationOffsetRef.current;
213
+ const tx = px - ox; // map NG → Vitessce
214
+ const ty = py - oy;
215
+ if (Number.isFinite(tx) && Math.abs(tx - (spatialTargetX ?? tx)) > TARGET_EPS) setTargetX(tx);
216
+ if (Number.isFinite(ty) && Math.abs(ty - (spatialTargetY ?? ty)) > TARGET_EPS) setTargetY(ty);
217
+ }
218
+ // ROTATION — only when NG quat actually changes
219
+ const quatChanged = valueGreaterThanEpsilon(
220
+ projectionOrientation, lastNgQuatRef.current, ROTATION_EPS,
221
+ );
222
+
223
+ if (quatChanged) {
224
+ if (applyNgUpdateTimeoutRef.current) clearTimeout(applyNgUpdateTimeoutRef.current);
225
+ lastNgPushOrientationRef.current = projectionOrientation;
226
+
227
+ applyNgUpdateTimeoutRef.current = setTimeout(() => {
228
+ // Remove the Y-up correction before converting to Euler for Vitessce
229
+ const qVit = multiplyQuat(conjQuat(Q_Y_UP), projectionOrientation);
230
+ const [pitchRad, yawRad] = quaternionToEuler(qVit); // radians
231
+ const currPitchRad = deg2rad(spatialRotationX ?? 0);
232
+ const currYawRad = deg2rad(spatialRotationOrbit ?? 0);
233
+
234
+ if (Math.abs(pitchRad - currPitchRad) > ROTATION_EPS
235
+ || Math.abs(yawRad - currYawRad) > ROTATION_EPS) {
236
+ const pitchDeg = rad2deg(pitchRad);
237
+ const yawDeg = rad2deg(yawRad);
238
+
239
+ // Mark Vitessce as the source for the next derived pass
240
+ lastInteractionSource.current = LAST_INTERACTION_SOURCE.vitessce;
241
+ setRotationX(pitchDeg);
242
+ setRotationOrbit(yawDeg);
243
+ ngRotPushAtRef.current = performance.now();
244
+
245
+ // // Test to verify rotation from NG to Vitessce and back to NG
246
+ // requestAnimationFrame(() => {
247
+ // requestAnimationFrame(() => {
248
+ // // Recreate the Vitessce quaternion from the angles we *just set*
249
+ // const qVitJustSet = eulerToQuaternion(deg2rad(pitchDeg), deg2rad(yawDeg), 0);
250
+ // // Convert to NG frame (apply Y-up)
251
+ // const qNgExpected = multiplyQuat(Q_Y_UP, qVitJustSet);
252
+ // // What NG is currently holding (latest from ref, fallback to local)
253
+ // const qNgCurrent = latestViewerStateRef.current?.projectionOrientation
254
+ // || projectionOrientation;
255
+
256
+ // const dot = quatdotAbs(qNgExpected, qNgCurrent);
257
+ // console.log('[POST-APPLY] |dot| =', dot.toFixed(6));
258
+ // });
259
+ // });
260
+ }
261
+ }, VITESSCE_INTERACTION_DELAY);
262
+
263
+ lastNgQuatRef.current = projectionOrientation;
264
+ }
134
265
 
135
- // Note: To pan in Neuroglancer, use shift+leftKey+drag
136
- setTargetX(position[0]);
137
- setTargetY(position[1]);
138
- }, [setZoom, setTargetX, setTargetY, setRotationX, setRotationY]);
266
+ latestViewerStateRef.current = {
267
+ ...latestViewerStateRef.current,
268
+ projectionOrientation,
269
+ projectionScale,
270
+ position,
271
+ };
272
+ }, []);
139
273
 
140
274
  const onSegmentClick = useCallback((value) => {
141
275
  if (value) {
142
- const selectedCellIds = [String(value)];
276
+ const id = String(value);
277
+ const selectedCellIds = [id];
278
+ const alreadySelectedId = cellSetSelection?.flat()?.some(sel => sel.includes(id));
279
+ // Don't create new selection from same ids
280
+ if (alreadySelectedId) {
281
+ return;
282
+ }
143
283
  setObsSelection(
144
284
  selectedCellIds, additionalCellSets, cellSetColor,
145
285
  setCellSetSelection, setAdditionalCellSets, setCellSetColor,
@@ -152,63 +292,214 @@ export function NeuroglancerSubscriber(props) {
152
292
  setCellColorEncoding, setCellSetColor, setCellSetSelection,
153
293
  ]);
154
294
 
155
- const mergedCellSets = useMemo(() => mergeObsSets(
156
- cellSets, additionalCellSets,
157
- ), [cellSets, additionalCellSets]);
158
-
159
- const cellColors = useMemo(() => getCellColors({
160
- cellSets: mergedCellSets,
161
- cellSetSelection,
162
- cellSetColor,
163
- obsIndex,
164
- theme,
165
- }), [mergedCellSets, theme,
166
- cellSetColor, cellSetSelection, obsIndex]);
295
+ const batchedUpdateTimeoutRef = useRef(null);
296
+ const [batchedCellColors, setBatchedCellColors] = useState(cellColors);
167
297
 
168
- const rgbToHex = useCallback(rgb => (typeof rgb === 'string' ? rgb
169
- : `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`), []);
298
+ useEffect(() => {
299
+ if (batchedUpdateTimeoutRef.current) {
300
+ clearTimeout(batchedUpdateTimeoutRef.current);
301
+ }
302
+ batchedUpdateTimeoutRef.current = setTimeout(() => {
303
+ setBatchedCellColors(cellColors);
304
+ }, 100);
170
305
 
306
+ // TODO: look into deferredValue from React
307
+ // startTransition(() => {
308
+ // setBatchedCellColors(cellColors);
309
+ // });
310
+ }, [cellColors]);
311
+ // TODO use a ref if slow - see prev commits
171
312
  const cellColorMapping = useMemo(() => {
172
- const colorCellMapping = {};
173
- cellColors.forEach((color, cell) => {
174
- colorCellMapping[cell] = rgbToHex(color);
313
+ const colorMapping = {};
314
+ batchedCellColors.forEach((color, cell) => {
315
+ colorMapping[cell] = rgbToHex(color);
175
316
  });
176
- return colorCellMapping;
177
- }, [cellColors, rgbToHex]);
178
-
179
- const derivedViewerState = useMemo(() => ({
180
- ...initialViewerState,
181
- layers: initialViewerState.layers.map((layer, index) => (index === 0
182
- ? {
183
- ...layer,
184
- segments: Object.keys(cellColorMapping).map(String),
185
- segmentColors: cellColorMapping,
317
+ return colorMapping;
318
+ }, [batchedCellColors]);
319
+
320
+
321
+ const derivedViewerState = useMemo(() => {
322
+ const { current } = latestViewerStateRef;
323
+ const nextSegments = Object.keys(cellColorMapping);
324
+ const prevLayer = current?.layers?.[0] || {};
325
+ const prevSegments = prevLayer.segments || [];
326
+ const { projectionScale, projectionOrientation, position } = current;
327
+
328
+ // Did Vitessce coords change vs the *previous* render?
329
+ const rotChangedNow = !nearEq(spatialRotationX, prevCoordsRef.current.rx, ROTATION_EPS)
330
+ || !nearEq(spatialRotationY, prevCoordsRef.current.ry, ROTATION_EPS)
331
+ || !nearEq(spatialRotationZ, prevCoordsRef.current.rz, ROTATION_EPS)
332
+ || !nearEq(spatialRotationOrbit, prevCoordsRef.current.orbit, ROTATION_EPS);
333
+
334
+ const zoomChangedNow = !nearEq(spatialZoom, prevCoordsRef.current.zoom, ROTATION_EPS);
335
+
336
+ const transChangedNow = !nearEq(spatialTargetX, prevCoordsRef.current.tx, ROTATION_EPS)
337
+ || !nearEq(spatialTargetY, prevCoordsRef.current.ty, ROTATION_EPS);
338
+
339
+ let nextProjectionScale = projectionScale;
340
+ let nextPosition = position;
341
+
342
+ // ** --- Zoom handling --- ** //
343
+ if (typeof spatialZoom === 'number'
344
+ && initialRenderCalibratorRef.current
345
+ && lastInteractionSource.current !== LAST_INTERACTION_SOURCE.neuroglancer
346
+ && zoomChangedNow) {
347
+ const s = initialRenderCalibratorRef.current.vitToNgZoom(spatialZoom);
348
+ if (Number.isFinite(s) && s > 0) {
349
+ nextProjectionScale = s;
186
350
  }
187
- : layer)),
188
- }), [cellColorMapping, initialViewerState]);
189
-
190
- const derivedViewerState2 = useMemo(() => {
191
- if (typeof spatialZoom === 'number' && typeof spatialTargetX === 'number') {
192
- const projectionScale = mapVitessceToNeuroglancer(spatialZoom);
193
- const position = [spatialTargetX, spatialTargetY, derivedViewerState.position[2]];
194
- const projectionOrientation = normalizeQuaternion(
195
- eulerToQuaternion(spatialRotationX, spatialRotationY),
196
- );
197
- return {
198
- ...derivedViewerState,
199
- projectionScale,
200
- position,
351
+ }
352
+
353
+ // ** --- Translation handling --- ** //
354
+ const [ox, oy, oz] = translationOffsetRef.current;
355
+ const [px = 0, py = 0, pz = (current.position?.[2] ?? oz)] = current.position || [];
356
+ const hasVitessceSpatialTarget = Number.isFinite(spatialTargetX)
357
+ && Number.isFinite(spatialTargetY);
358
+ if (hasVitessceSpatialTarget
359
+ && lastInteractionSource.current !== LAST_INTERACTION_SOURCE.neuroglancer
360
+ && transChangedNow) {
361
+ const nx = spatialTargetX + ox; // Vitessce → NG
362
+ const ny = spatialTargetY + oy;
363
+ if (Math.abs(nx - px) > TARGET_EPS || Math.abs(ny - py) > TARGET_EPS) {
364
+ nextPosition = [nx, ny, pz];
365
+ }
366
+ }
367
+
368
+ // ** --- Orientation/Rotation handling --- ** //
369
+ const vitessceRotationRaw = eulerToQuaternion(
370
+ deg2rad(spatialRotationX ?? 0),
371
+ deg2rad(spatialRotationOrbit ?? 0),
372
+ deg2rad(spatialRotationZ ?? 0),
373
+ );
374
+
375
+ // Apply Y-up to have both views with same axis-direction (xy)
376
+ const vitessceRotation = multiplyQuat(Q_Y_UP, vitessceRotationRaw);
377
+
378
+ // // Round-trip check: NG -> Vit (remove Y-UP)
379
+ // const qVitBack = multiplyQuat(conjQuat(Q_Y_UP), vitessceRotation);
380
+ // const dotVitLoop = quatdotAbs(qVitBack, vitessceRotationRaw);
381
+
382
+ // // Expect ~1 (± sign OK)
383
+ // const fmt = (v) => Array.isArray(v) ? v.map(n => Number(n).toFixed(6)) : v;
384
+ // console.log('[CHK Vit→NG→Vit] |dot| =', dotVitLoop.toFixed(6),
385
+ // ' qVitRaw=', fmt(vitessceRotationRaw),
386
+ // ' qVitBack=', fmt(qVitBack));
387
+
388
+ // // Cross-view check: does the NG orientation we're about to send match our Vit -> NG?
389
+ // const dotVsNg = quatdotAbs(vitessceRotation, projectionOrientation);
390
+ // console.log('[CHK Vit→NG vs current NG] |dot| =', dotVsNg.toFixed(6));
391
+
392
+ // If NG quat != Vitessce quat on first render, push Vitessce once.
393
+ const shouldForceInitialVitPush = !initialRotationPushedRef.current
394
+ && valueGreaterThanEpsilon(vitessceRotation, projectionOrientation, ROTATION_EPS);
395
+
396
+ // Use explicit source if set; otherwise infer Vitessce when coords changed.
397
+ const ngFresh = (performance.now() - (ngRotPushAtRef.current || 0)) < NG_ROT_COOLDOWN_MS;
398
+
399
+ const changedNowOrIInitialVitPush = rotChangedNow
400
+ || zoomChangedNow || transChangedNow || shouldForceInitialVitPush;
401
+
402
+ const src = ngFresh ? LAST_INTERACTION_SOURCE.neuroglancer
403
+ : (lastInteractionSource.current
404
+ ?? (changedNowOrIInitialVitPush ? LAST_INTERACTION_SOURCE.vitessce : null));
405
+
406
+
407
+ let nextOrientation = projectionOrientation; // start from NG's current quat
408
+
409
+ // console.log('[ORIENT]',
410
+ // 'srcResolved=', src,
411
+ // 'lastSource=', lastInteractionSource.current,
412
+ // 'dotLoop=', dotVitLoop.toFixed(6),
413
+ // 'dotCross=', dotVsNg.toFixed(6)
414
+ // );
415
+
416
+ // console.log('[ORIENT Q]',
417
+ // 'qVitRaw=', fmt(vitessceRotationRaw), // Vit frame (pre Y-up)
418
+ // 'qVitToNg=', fmt(vitessceRotation), // NG frame (post Y-up)
419
+ // 'qNgCurr=', fmt(projectionOrientation),
420
+ // );
421
+
422
+
423
+ if (src === LAST_INTERACTION_SOURCE.vitessce) {
424
+ // Only push if Vitessce rotation actually changed since last time.
425
+ const rotDiffers = valueGreaterThanEpsilon(
426
+ vitessceRotation,
201
427
  projectionOrientation,
202
- };
428
+ ROTATION_EPS,
429
+ );
430
+
431
+ if (rotDiffers) {
432
+ nextOrientation = vitessceRotation;
433
+ lastVitessceRotationRef.current = {
434
+ x: spatialRotationX,
435
+ y: spatialRotationY,
436
+ z: spatialRotationZ,
437
+ orbit: spatialRotationOrbit,
438
+ };
439
+ initialRotationPushedRef.current = true;
440
+ // Re-anchor NG -> Vitessce translation once we commit the initial orientation,
441
+ // the center shows a right translated image
442
+ const [cx = 0, cy = 0,
443
+ cz = (nextPosition?.[2] ?? current.position?.[2] ?? 0),
444
+ ] = nextPosition
445
+ || current.position || [];
446
+ const tX = Number.isFinite(spatialTargetX) ? spatialTargetX : 0;
447
+ const tY = Number.isFinite(spatialTargetY) ? spatialTargetY : 0;
448
+ translationOffsetRef.current = [cx - tX, cy - tY, cz];
449
+ }
450
+ // else {
451
+ // // No real Vitessce rotation change → do not overwrite NG's quat.
452
+ // console.log('Vitessce → NG: no rotation change, keep NG quat');
453
+ // }
454
+ if (lastInteractionSource.current === LAST_INTERACTION_SOURCE.vitessce) {
455
+ lastInteractionSource.current = null;
456
+ }
457
+ } else if (src === LAST_INTERACTION_SOURCE.neuroglancer) {
458
+ nextOrientation = lastNgPushOrientationRef.current ?? projectionOrientation;
459
+ lastInteractionSource.current = null;
203
460
  }
204
- return derivedViewerState;
205
- }, [derivedViewerState, spatialZoom, spatialTargetX,
206
- spatialTargetY, spatialRotationX, spatialRotationY]);
461
+
462
+ const newLayer0 = {
463
+ ...prevLayer,
464
+ segments: nextSegments,
465
+ segmentColors: cellColorMapping,
466
+ };
467
+
468
+
469
+ const updated = {
470
+ ...current,
471
+ projectionScale: nextProjectionScale,
472
+ projectionOrientation: nextOrientation,
473
+ position: nextPosition,
474
+ layers: prevSegments.length === 0 ? [newLayer0, ...(current?.layers?.slice(1)
475
+ || [])] : current?.layers,
476
+ };
477
+
478
+ latestViewerStateRef.current = updated;
479
+
480
+ prevCoordsRef.current = {
481
+ zoom: spatialZoom,
482
+ rx: spatialRotationX,
483
+ ry: spatialRotationY,
484
+ rz: spatialRotationZ,
485
+ orbit: spatialRotationOrbit,
486
+ tx: spatialTargetX,
487
+ ty: spatialTargetY,
488
+ };
489
+
490
+ return updated;
491
+ }, [cellColorMapping, spatialZoom, spatialRotationX, spatialRotationY,
492
+ spatialRotationZ, spatialTargetX, spatialTargetY]);
207
493
 
208
494
  const onSegmentHighlight = useCallback((obsId) => {
209
495
  setCellHighlight(String(obsId));
210
496
  }, [obsIndex, setCellHighlight]);
211
497
 
498
+ // TODO: if all cells are deselected, a black view is shown, rather we want to show empty NG view?
499
+ // if (!cellColorMapping || Object.keys(cellColorMapping).length === 0) {
500
+ // return;
501
+ // }
502
+
212
503
  return (
213
504
  <TitleInfo
214
505
  title={title}
@@ -221,11 +512,12 @@ export function NeuroglancerSubscriber(props) {
221
512
  isReady
222
513
  withPadding={false}
223
514
  >
224
- <Neuroglancer
515
+ <NeuroglancerComp
225
516
  classes={classes}
226
517
  onSegmentClick={onSegmentClick}
227
518
  onSelectHoveredCoords={onSegmentHighlight}
228
- viewerState={derivedViewerState2}
519
+ viewerState={derivedViewerState}
520
+ cellColorMapping={cellColorMapping}
229
521
  setViewerState={handleStateUpdate}
230
522
  />
231
523
  </TitleInfo>