Skip to content

Commit b43169e

Browse files
patconclaude
andcommitted
Animate embedding switching via D3Map pipeline system
Reuse D3Map's existing PipelineSelector and animation system for h5ad embedding switching instead of re-creating preloadedData on each change. All embeddings are now parsed once and injected via preloadedPipelineData prop, preserving painted groups and providing smooth 1s transitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 19e1fdd commit b43169e

4 files changed

Lines changed: 76 additions & 94 deletions

File tree

src/components/convo-explorer/App.h5ad.stories.tsx

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,16 @@ import type { Meta, StoryObj } from '@storybook/react';
33
import { App } from './App';
44
import type { PreloadedData } from './App';
55
import { loadH5adFile } from '../../lib/h5ad-loader';
6-
import type { H5adData } from '../../lib/h5ad-loader';
76

87
/**
98
* Wrapper component that provides a file picker for loading h5ad files,
109
* parses them with h5wasm, and renders the App with preloaded data.
10+
* Embedding switching is handled by D3Map's PipelineSelector with animation.
1111
*/
1212
function H5adFileLoader() {
13-
const [data, setData] = React.useState<H5adData | null>(null);
13+
const [preloadedData, setPreloadedData] = React.useState<PreloadedData | null>(null);
1414
const [loading, setLoading] = React.useState(false);
1515
const [error, setError] = React.useState<string | null>(null);
16-
const [selectedEmbedding, setSelectedEmbedding] = React.useState<string | null>(null);
17-
// Store the file buffer so we can re-parse with a different embedding
18-
// without needing the file input element (which is removed from DOM after loading)
19-
const bufferRef = React.useRef<ArrayBuffer | null>(null);
2016

2117
const handleFileChange = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
2218
const file = event.target.files?.[0];
@@ -27,14 +23,13 @@ function H5adFileLoader() {
2723

2824
try {
2925
const buffer = await file.arrayBuffer();
30-
bufferRef.current = buffer;
3126
const parsed = await loadH5adFile(buffer);
32-
setData(parsed);
33-
// Set the initially-selected embedding (first in preferred order)
34-
const preferredOrder = ['X_localmap', 'X_umap', 'X_pacmap'];
35-
const defaultEmbedding = preferredOrder.find(k => parsed.availableEmbeddings.includes(k))
36-
?? parsed.availableEmbeddings[0];
37-
setSelectedEmbedding(defaultEmbedding);
27+
setPreloadedData({
28+
dataset: parsed.dataset,
29+
statements: parsed.statements,
30+
votesRows: parsed.votesRows,
31+
pipelineData: parsed.allEmbeddings,
32+
});
3833
} catch (err) {
3934
console.error('Failed to load h5ad file:', err);
4035
setError(err instanceof Error ? err.message : 'Failed to parse h5ad file');
@@ -43,25 +38,8 @@ function H5adFileLoader() {
4338
}
4439
}, []);
4540

46-
const handleEmbeddingChange = React.useCallback(async (newEmbedding: string) => {
47-
if (!bufferRef.current) return;
48-
setSelectedEmbedding(newEmbedding);
49-
setLoading(true);
50-
setError(null);
51-
52-
try {
53-
const parsed = await loadH5adFile(bufferRef.current, newEmbedding);
54-
setData(parsed);
55-
} catch (err) {
56-
console.error('Failed to reload with new embedding:', err);
57-
setError(err instanceof Error ? err.message : 'Failed to reload embedding');
58-
} finally {
59-
setLoading(false);
60-
}
61-
}, []);
62-
6341
// File picker screen
64-
if (!data) {
42+
if (!preloadedData) {
6543
return (
6644
<div className="h-screen w-screen flex items-center justify-center bg-background">
6745
<div className="flex flex-col items-center gap-4 max-w-md text-center p-8">
@@ -93,39 +71,7 @@ function H5adFileLoader() {
9371
);
9472
}
9573

96-
const preloadedData: PreloadedData = {
97-
dataset: data.dataset,
98-
statements: data.statements,
99-
votesRows: data.votesRows,
100-
};
101-
102-
return (
103-
<div className="relative h-screen w-screen">
104-
{/* Embedding selector overlay */}
105-
{data.availableEmbeddings.length > 1 && (
106-
<div className="absolute top-2 left-2 z-[100] pointer-events-auto">
107-
<select
108-
value={selectedEmbedding ?? ''}
109-
onChange={(e) => handleEmbeddingChange(e.target.value)}
110-
disabled={loading}
111-
className="text-xs px-2 py-1 rounded border bg-background/90 backdrop-blur-sm shadow-sm"
112-
>
113-
{data.availableEmbeddings.map((key) => (
114-
<option key={key} value={key}>
115-
{key.replace(/^X_/, '')}
116-
</option>
117-
))}
118-
</select>
119-
</div>
120-
)}
121-
{loading && (
122-
<div className="absolute inset-0 z-[200] flex items-center justify-center bg-background/50">
123-
<p className="text-sm text-muted-foreground">Switching embedding...</p>
124-
</div>
125-
)}
126-
<App preloadedData={preloadedData} />
127-
</div>
128-
);
74+
return <App preloadedData={preloadedData} />;
12975
}
13076

13177
const meta: Meta = {

src/components/convo-explorer/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type PreloadedData = {
3535
dataset: [string, [number, number]][];
3636
statements: { statement_id: string; txt: string; moderated: number }[];
3737
votesRows: { participant_id: string; comment_id: string; vote: number }[];
38+
pipelineData?: Record<string, [string, [number, number]][]>;
3839
};
3940

4041
type AppProps = {
@@ -691,11 +692,12 @@ export const App: React.FC<AppProps> = ({ testAnimation = false, kedroBaseUrl, i
691692
flipX={currentDisplayState.flipX}
692693
flipY={currentDisplayState.flipY}
693694
colorsToFront={colorsToFront}
694-
testAnimation={testAnimation}
695+
testAnimation={testAnimation || !!preloadedData?.pipelineData}
695696
kedroBaseUrl={kedroBaseUrl}
696697
pipelineFilter={pipelineFilter}
697698
availablePipelines={kedroBaseUrl ? [] : undefined} // Will be populated by D3Map's usePipelineOptions
698699
onPipelineChange={handlePipelineChange}
700+
preloadedPipelineData={preloadedData?.pipelineData}
699701
/>
700702
</div>
701703
</div>

src/components/convo-explorer/D3Map.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type D3MapProps = {
4242
availablePipelines?: Array<{id: string, name: string}>;
4343
/** Called when pipeline changes in the selector */
4444
onPipelineChange?: (pipelineId: string) => void;
45+
/** Preloaded pipeline data (e.g. from h5ad file) — bypasses fetch-based loading */
46+
preloadedPipelineData?: Record<string, [string, [number, number]][] | null>;
4547
};
4648

4749
const PREFERRED_KEDRO_PIPELINE = 'mean_localmap_bestkmeans';
@@ -64,6 +66,7 @@ export const D3Map: React.FC<D3MapProps> = ({
6466
pipelineFilter,
6567
availablePipelines = [],
6668
onPipelineChange,
69+
preloadedPipelineData,
6770
}) => {
6871
const svgRef = React.useRef<SVGSVGElement>(null);
6972
const containerRef = React.useRef<d3.Selection<SVGGElement, unknown, null, undefined> | null>(null);
@@ -103,8 +106,14 @@ export const D3Map: React.FC<D3MapProps> = ({
103106
);
104107
const kedroOptions = availablePipelines?.length ? availablePipelines : fetchedKedroOptions;
105108

109+
// Preloaded pipeline options derived from preloadedPipelineData keys
110+
const preloadedPipelineOptions = React.useMemo(() => {
111+
if (!preloadedPipelineData) return [];
112+
return Object.keys(preloadedPipelineData).map(id => ({ id, name: id }));
113+
}, [preloadedPipelineData]);
114+
106115
// Current pipeline options based on mode
107-
const currentPipelineOptions = isKedroMode ? kedroOptions : staticPipelines;
116+
const currentPipelineOptions = preloadedPipelineData ? preloadedPipelineOptions : isKedroMode ? kedroOptions : staticPipelines;
108117

109118
// Auto-cycling state
110119
const [isAutoCycling, setIsAutoCycling] = React.useState(false);
@@ -115,7 +124,12 @@ export const D3Map: React.FC<D3MapProps> = ({
115124
// Initialize selectedPipeline when pipeline options become available
116125
React.useEffect(() => {
117126
if (currentPipelineOptions.length > 0 && !selectedPipeline) {
118-
if (isKedroMode) {
127+
if (preloadedPipelineData) {
128+
// For preloaded data, use preferred order: localmap > umap > pacmap > first key
129+
const preferredOrder = ['localmap', 'umap', 'pacmap'];
130+
const preferred = preferredOrder.find(id => id in preloadedPipelineData);
131+
setSelectedPipeline(preferred || currentPipelineOptions[0].id);
132+
} else if (isKedroMode) {
119133
// Prioritize preferred Kedro pipeline if available, otherwise use first
120134
const preferredPipeline = currentPipelineOptions.find(p => p.id === PREFERRED_KEDRO_PIPELINE);
121135
const defaultPipeline = preferredPipeline || currentPipelineOptions[0];
@@ -125,7 +139,7 @@ export const D3Map: React.FC<D3MapProps> = ({
125139
setSelectedPipeline('localmap');
126140
}
127141
}
128-
}, [currentPipelineOptions, selectedPipeline, isKedroMode, testAnimation]);
142+
}, [currentPipelineOptions, selectedPipeline, isKedroMode, testAnimation, preloadedPipelineData]);
129143

130144
// Handle window resize to update radius (with throttling)
131145
React.useEffect(() => {
@@ -149,6 +163,12 @@ export const D3Map: React.FC<D3MapProps> = ({
149163
React.useEffect(() => {
150164
if (!testAnimation) return;
151165

166+
// If preloaded pipeline data is provided, use it directly
167+
if (preloadedPipelineData) {
168+
setPipelineData(preloadedPipelineData);
169+
return;
170+
}
171+
152172
const loadProjections = async () => {
153173
try {
154174
if (isKedroMode && kedroOptions.length > 0) {
@@ -197,7 +217,7 @@ export const D3Map: React.FC<D3MapProps> = ({
197217
};
198218

199219
loadProjections();
200-
}, [testAnimation, isKedroMode, kedroOptions]);
220+
}, [testAnimation, isKedroMode, kedroOptions, preloadedPipelineData]);
201221

202222
// Calculate responsive base radius directly in JavaScript
203223
const BASE_RADIUS = React.useMemo(() => {

src/lib/h5ad-loader.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export type H5adData = {
66
statements: { statement_id: string; txt: string; moderated: number }[];
77
votesRows: { participant_id: string; comment_id: string; vote: number }[];
88
availableEmbeddings: string[];
9+
/** All 2D embeddings keyed by pipeline-style ID (X_ prefix stripped) */
10+
allEmbeddings: Record<string, [string, [number, number]][]>;
911
};
1012

1113
/**
@@ -148,35 +150,47 @@ export async function loadH5adFile(
148150
if (!obsGroup) throw new Error('Missing /obs group');
149151
const obsNames = readIndex(obsGroup);
150152

151-
// --- Read embedding coordinates ---
153+
// --- Read embedding coordinates for all available embeddings ---
152154
const obsmGroup = file.get('obsm') as Group;
153155
if (!obsmGroup) throw new Error('Missing /obsm group');
154-
const embeddingDs = obsmGroup.get(selectedEmbedding) as Dataset | null;
155-
if (!embeddingDs) throw new Error(`Embedding "${selectedEmbedding}" not found in /obsm`);
156156

157-
const shape = embeddingDs.shape;
158-
if (!shape || shape.length !== 2) {
159-
throw new Error(`Embedding "${selectedEmbedding}" has unexpected shape`);
160-
}
161-
const nObs = shape[0];
162-
const nDims = shape[1];
163-
164-
// Read the flat typed array and reshape to [n_obs, 2]
165-
const rawValue = embeddingDs.value;
166-
let flatCoords: number[];
167-
if (ArrayBuffer.isView(rawValue)) {
168-
flatCoords = Array.from(rawValue as Float64Array | Float32Array);
169-
} else if (Array.isArray(rawValue)) {
170-
flatCoords = (rawValue as number[][]).flat();
171-
} else {
172-
throw new Error(`Unexpected embedding data format`);
157+
const allEmbeddings: Record<string, [string, [number, number]][]> = {};
158+
for (const embKey of availableEmbeddings) {
159+
const ds = obsmGroup.get(embKey) as Dataset | null;
160+
if (!ds) continue;
161+
162+
const shape = ds.shape;
163+
if (!shape || shape.length !== 2) continue;
164+
const nObs = shape[0];
165+
const nDims = shape[1];
166+
167+
const rawValue = ds.value;
168+
let flatCoords: number[];
169+
if (ArrayBuffer.isView(rawValue)) {
170+
flatCoords = Array.from(rawValue as Float64Array | Float32Array);
171+
} else if (Array.isArray(rawValue)) {
172+
flatCoords = (rawValue as number[][]).flat();
173+
} else {
174+
continue;
175+
}
176+
177+
const embDataset: [string, [number, number]][] = [];
178+
for (let i = 0; i < nObs; i++) {
179+
const x = flatCoords[i * nDims];
180+
const y = flatCoords[i * nDims + 1];
181+
embDataset.push([obsNames[i], [x, y]]);
182+
}
183+
184+
// Strip X_ prefix for pipeline-style IDs
185+
const pipelineId = embKey.replace(/^X_/, '');
186+
allEmbeddings[pipelineId] = embDataset;
173187
}
174188

175-
const dataset: [string, [number, number]][] = [];
176-
for (let i = 0; i < nObs; i++) {
177-
const x = flatCoords[i * nDims];
178-
const y = flatCoords[i * nDims + 1];
179-
dataset.push([obsNames[i], [x, y]]);
189+
// Use the selected embedding as the default dataset
190+
const pipelineId = selectedEmbedding.replace(/^X_/, '');
191+
const dataset = allEmbeddings[pipelineId];
192+
if (!dataset) {
193+
throw new Error(`Embedding "${selectedEmbedding}" could not be read from /obsm`);
180194
}
181195

182196
// --- Read statements (var) ---
@@ -224,7 +238,7 @@ export async function loadH5adFile(
224238
// --- Read votes from uns/votes ---
225239
const votesRows = readVotes(file);
226240

227-
return { dataset, statements, votesRows, availableEmbeddings };
241+
return { dataset, statements, votesRows, availableEmbeddings, allEmbeddings };
228242
} finally {
229243
if (file) {
230244
file.close();

0 commit comments

Comments
 (0)