Skip to content

Commit c74724b

Browse files
rec3141claude
andcommitted
Fix Svelte 5 store pattern: use single $state object
Wrapped all reactive state in a single exported `store` object. Updated all views to use store.X property access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b4e787 commit c74724b

5 files changed

Lines changed: 122 additions & 134 deletions

File tree

nextflow/viz/src/App.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import SampleView from './views/SampleView.svelte';
55
import NetworkView from './views/NetworkView.svelte';
66
import TablesView from './views/TablesView.svelte';
7-
import { loadData, loading, error } from './stores/data.svelte.js';
7+
import { store, loadData } from './stores/data.svelte.js';
88
99
let activeTab = $state('samples');
1010
@@ -27,17 +27,17 @@
2727
<NavBar {activeTab} />
2828

2929
<main class="flex-1 overflow-hidden">
30-
{#if loading}
30+
{#if store.loading}
3131
<div class="flex h-full items-center justify-center">
3232
<div class="text-center">
3333
<div class="mb-4 h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent mx-auto"></div>
3434
<p class="text-sm text-slate-400">Loading data...</p>
3535
</div>
3636
</div>
37-
{:else if error}
37+
{:else if store.error}
3838
<div class="flex h-full items-center justify-center">
3939
<div class="rounded-lg border border-red-800 bg-red-950/50 p-6 text-center">
40-
<p class="text-red-400">{error}</p>
40+
<p class="text-red-400">{store.error}</p>
4141
<button
4242
class="mt-3 rounded bg-red-800 px-4 py-1.5 text-sm text-red-100 hover:bg-red-700"
4343
onclick={() => loadData()}
Lines changed: 91 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,112 @@
11
/**
2-
* Reactive data stores for the microscape amplicon pipeline visualization.
3-
* Uses Svelte 5 runes ($state) with setter functions for cross-module access.
2+
* Reactive data stores for microscape visualization.
3+
*
4+
* Svelte 5 module-level $state can't be exported AND reassigned.
5+
* Solution: wrap all state in a single exported object whose
6+
* properties are mutated (not reassigned).
47
*/
58

6-
// ── Core data ───────────────────────────────────────────────────────────────
9+
export const store = $state({
10+
// Core data
11+
samples: [],
12+
asvs: [],
13+
counts: { data: [], samples: [], asvs: [] },
14+
network: { edges: [] },
15+
taxonomy: {},
716

8-
export let samples = $state([]);
9-
export let asvs = $state([]);
10-
export let counts = $state([]);
11-
export let network = $state([]);
12-
export let taxonomy = $state({});
17+
// Selection
18+
selectedSample: null,
19+
selectedAsv: null,
1320

14-
// ── Selection state ─────────────────────────────────────────────────────────
21+
// UI
22+
loading: true,
23+
error: null,
24+
});
1525

16-
export let selectedSample = $state(null);
17-
export let selectedAsv = $state(null);
18-
19-
/** Setter functions for cross-module mutation */
20-
export function setSelectedSample(v) { selectedSample = v; }
21-
export function setSelectedAsv(v) { selectedAsv = v; }
22-
23-
// ── UI state ────────────────────────────────────────────────────────────────
26+
/** Group colors as RGBA for regl-scatterplot */
27+
export const GROUP_COLORS = {
28+
prokaryote: [0.3, 0.5, 1.0, 0.8],
29+
eukaryote: [1.0, 0.3, 0.3, 0.8],
30+
chloroplast: [0.2, 0.85, 0.4, 0.8],
31+
mitochondria: [0.2, 0.9, 0.9, 0.8],
32+
unknown: [0.6, 0.6, 0.6, 0.5],
33+
};
2434

25-
export let loading = $state(true);
26-
export let error = $state(null);
35+
/** Group colors as hex for UI */
36+
export const GROUP_HEX = {
37+
prokaryote: '#4d80ff',
38+
eukaryote: '#ff4d4d',
39+
chloroplast: '#33d966',
40+
mitochondria: '#33e6e6',
41+
unknown: '#999999',
42+
};
2743

2844
// ── Data loading ────────────────────────────────────────────────────────────
2945

3046
async function fetchJson(url) {
47+
// Try .gz first
48+
try {
49+
const gzRes = await fetch(url + '.gz');
50+
if (gzRes.ok) {
51+
const buf = await gzRes.arrayBuffer();
52+
const bytes = new Uint8Array(buf);
53+
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
54+
const ds = new DecompressionStream('gzip');
55+
const reader = new Blob([buf]).stream().pipeThrough(ds).getReader();
56+
const chunks = [];
57+
while (true) {
58+
const { done, value } = await reader.read();
59+
if (done) break;
60+
chunks.push(value);
61+
}
62+
const combined = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0));
63+
let offset = 0;
64+
for (const c of chunks) { combined.set(c, offset); offset += c.length; }
65+
return JSON.parse(new TextDecoder().decode(combined));
66+
}
67+
return JSON.parse(new TextDecoder().decode(buf));
68+
}
69+
} catch (_) { /* fall through */ }
70+
3171
const res = await fetch(url);
32-
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
72+
if (!res.ok) throw new Error(`${res.status} ${url}`);
3373
return res.json();
3474
}
3575

3676
export async function loadData() {
37-
loading = true;
38-
error = null;
39-
40-
const files = [
41-
{ key: 'samples', url: '/data/samples.json' },
42-
{ key: 'asvs', url: '/data/asvs.json' },
43-
{ key: 'counts', url: '/data/counts.json' },
44-
{ key: 'network', url: '/data/network.json' },
45-
{ key: 'taxonomy', url: '/data/taxonomy.json' },
46-
];
47-
48-
const results = {};
49-
50-
await Promise.all(
51-
files.map(async ({ key, url }) => {
52-
try {
53-
// Try gzipped first
54-
let gzUrl = url + '.gz';
55-
let res = await fetch(gzUrl);
56-
if (res.ok) {
57-
// Check if it's actually gzipped
58-
const buf = await res.arrayBuffer();
59-
const bytes = new Uint8Array(buf);
60-
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
61-
// Decompress
62-
const ds = new DecompressionStream('gzip');
63-
const reader = new Blob([buf]).stream().pipeThrough(ds).getReader();
64-
const chunks = [];
65-
while (true) {
66-
const { done, value } = await reader.read();
67-
if (done) break;
68-
chunks.push(value);
69-
}
70-
const text = new TextDecoder().decode(
71-
new Uint8Array(chunks.reduce((acc, c) => [...acc, ...c], []))
72-
);
73-
results[key] = JSON.parse(text);
74-
} else {
75-
// Not actually gzipped, parse as text
76-
results[key] = JSON.parse(new TextDecoder().decode(buf));
77-
}
78-
} else {
79-
// Fall back to plain JSON
80-
results[key] = await fetchJson(url);
81-
}
82-
} catch (e) {
83-
console.warn(`[microscape-viz] Could not load ${url}:`, e.message);
84-
results[key] = key === 'taxonomy' ? {} : [];
85-
}
86-
})
87-
);
88-
89-
samples = results.samples || [];
90-
asvs = results.asvs || [];
91-
counts = results.counts || [];
92-
network = results.network || [];
93-
taxonomy = results.taxonomy || {};
77+
store.loading = true;
78+
store.error = null;
79+
80+
try {
81+
const [samples, asvs, counts, network, taxonomy] = await Promise.all([
82+
fetchJson('/data/samples.json').catch(() => []),
83+
fetchJson('/data/asvs.json').catch(() => []),
84+
fetchJson('/data/counts.json').catch(() => ({ data: [], samples: [], asvs: [] })),
85+
fetchJson('/data/network.json').catch(() => ({ edges: [] })),
86+
fetchJson('/data/taxonomy.json').catch(() => ({})),
87+
]);
88+
89+
store.samples = samples;
90+
store.asvs = asvs;
91+
store.counts = counts;
92+
store.network = network;
93+
store.taxonomy = taxonomy;
94+
} catch (e) {
95+
store.error = e.message;
96+
console.error('[microscape] Data load failed:', e);
97+
}
9498

95-
loading = false;
99+
store.loading = false;
96100
}
97101

98-
// ── Derived helpers ─────────────────────────────────────────────────────────
102+
// ── Helpers ─────────────────────────────────────────────────────────────────
99103

100104
export function countsBySample() {
101105
const map = new Map();
102-
if (!counts || !counts.data) return map;
103-
for (const [si, ai, count, prop] of counts.data) {
106+
const data = store.counts?.data;
107+
if (!data) return map;
108+
for (const row of data) {
109+
const [si, ai, count, prop] = row;
104110
if (!map.has(si)) map.set(si, []);
105111
map.get(si).push({ asv_idx: ai, count, proportion: prop });
106112
}
@@ -109,28 +115,12 @@ export function countsBySample() {
109115

110116
export function countsByAsv() {
111117
const map = new Map();
112-
if (!counts || !counts.data) return map;
113-
for (const [si, ai, count, prop] of counts.data) {
118+
const data = store.counts?.data;
119+
if (!data) return map;
120+
for (const row of data) {
121+
const [si, ai, count, prop] = row;
114122
if (!map.has(ai)) map.set(ai, []);
115123
map.get(ai).push({ sample_idx: si, count, proportion: prop });
116124
}
117125
return map;
118126
}
119-
120-
/** Group colors as RGBA arrays for regl-scatterplot */
121-
export const GROUP_COLORS = {
122-
prokaryote: [0.3, 0.5, 1.0, 0.8],
123-
eukaryote: [1.0, 0.3, 0.3, 0.8],
124-
chloroplast: [0.2, 0.85, 0.4, 0.8],
125-
mitochondria: [0.2, 0.9, 0.9, 0.8],
126-
unknown: [0.6, 0.6, 0.6, 0.5],
127-
};
128-
129-
/** Group colors as hex for UI elements */
130-
export const GROUP_HEX = {
131-
prokaryote: '#4d80ff',
132-
eukaryote: '#ff4d4d',
133-
chloroplast: '#33d966',
134-
mitochondria: '#33e6e6',
135-
unknown: '#999999',
136-
};

nextflow/viz/src/views/NetworkView.svelte

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
import { onMount } from 'svelte';
33
import createScatterplot from 'regl-scatterplot';
44
import {
5-
asvs, network, counts,
6-
selectedAsv, setSelectedAsv, countsByAsv,
5+
store, countsByAsv,
76
GROUP_COLORS, GROUP_HEX,
87
} from '../stores/data.svelte.js';
98
@@ -30,7 +29,7 @@
3029
/** Filtered ASVs */
3130
let filteredAsvs = $derived.by(() => {
3231
const re = taxonRe();
33-
return asvs.filter(a => {
32+
return store.asvs.filter(a => {
3433
if ((a.prevalence ?? 0) < minPrevalence) return false;
3534
if (re && !(re.test(a.taxonomy ?? '') || re.test(a.id ?? ''))) return false;
3635
return true;
@@ -41,7 +40,7 @@
4140
let idxMap = $derived.by(() => {
4241
const m = new Map();
4342
filteredAsvs.forEach((a, fi) => {
44-
const oi = asvs.indexOf(a);
43+
const oi = store.asvs.indexOf(a);
4544
m.set(oi, fi);
4645
});
4746
return m;
@@ -50,15 +49,15 @@
5049
/** Filtered edges */
5150
let filteredEdges = $derived.by(() => {
5251
if (!showEdges) return [];
53-
return network.filter(e => {
52+
return (store.network?.edges ?? store.network ?? []).filter(e => {
5453
if (Math.abs(e.weight ?? 0) < corrThreshold) return false;
5554
return idxMap.has(e.source) && idxMap.has(e.target);
5655
});
5756
});
5857
5958
// ── Selected ASV detail ───────────────────────────────────────────────────
6059
let selectedAsvObj = $derived(
61-
selectedAsv != null ? asvs[selectedAsv] : null
60+
store.selectedAsv != null ? store.asvs[store.selectedAsv] : null
6261
);
6362
6463
// ── Scatterplot lifecycle ─────────────────────────────────────────────────
@@ -98,8 +97,8 @@
9897
9998
sp.subscribe('select', ({ points: indices }) => {
10099
if (indices.length > 0) {
101-
const oi = asvs.indexOf(filteredAsvs[indices[0]]);
102-
setSelectedAsv(oi >= 0 ? oi : null);
100+
const oi = store.asvs.indexOf(filteredAsvs[indices[0]]);
101+
store.selectedAsv = oi >= 0 ? oi : null;
103102
}
104103
});
105104
@@ -218,7 +217,7 @@
218217
{group}
219218
</div>
220219
{/each}
221-
<p class="text-xs text-slate-500 mt-2">{filteredAsvs.length} / {asvs.length} ASVs shown</p>
220+
<p class="text-xs text-slate-500 mt-2">{filteredAsvs.length} / {store.asvs.length} ASVs shown</p>
222221
<p class="text-xs text-slate-500">{filteredEdges.length} edges above threshold</p>
223222
</div>
224223
</aside>
@@ -258,7 +257,7 @@
258257
</h3>
259258
<button
260259
class="text-xs text-slate-500 hover:text-slate-300"
261-
onclick={() => setSelectedAsv(null)}
260+
onclick={() => store.selectedAsv = null}
262261
>Close</button>
263262
</div>
264263
<p class="mt-1 text-xs text-slate-400">{selectedAsvObj.taxonomy ?? 'No taxonomy'}</p>

0 commit comments

Comments
 (0)