|
175 | 175 | <button :class="{ active: vizTab === 'prevalence' }" @click="vizTab = 'prevalence'">{{ $t('data.prevalence') }}</button> |
176 | 176 | <button :class="{ active: vizTab === 'abundance' }" @click="vizTab = 'abundance'">{{ $t('data.abundance') }}</button> |
177 | 177 | <button :class="{ active: vizTab === 'barcode' }" @click="vizTab = 'barcode'">{{ $t('data.barcode') }}</button> |
| 178 | + <button :class="{ active: vizTab === 'classHeatmap' }" @click="vizTab = 'classHeatmap'">{{ $t('data.classHeatmap') }}</button> |
178 | 179 | <button :class="{ active: vizTab === 'volcano' }" @click="vizTab = 'volcano'">{{ $t('data.volcano') }}</button> |
179 | 180 | <button :class="{ active: vizTab === 'pcoa' }" @click="vizTab = 'pcoa'">{{ $t('data.pcoa') }}</button> |
180 | 181 | </nav> |
|
213 | 214 |
|
214 | 215 | <!-- Barcode heatmap --> |
215 | 216 | <section class="section viz-section" v-if="vizTab === 'barcode'"> |
| 217 | + <div class="viz-controls"> |
| 218 | + <label class="toggle-label"> |
| 219 | + <span>{{ $t('data.transform') }}</span> |
| 220 | + <select v-model="dataTransform" @change="loadBarcodeData"> |
| 221 | + <option value="raw">Raw</option> |
| 222 | + <option value="log">Log</option> |
| 223 | + <option value="zscore">Z-score</option> |
| 224 | + </select> |
| 225 | + </label> |
| 226 | + </div> |
216 | 227 | <div ref="barcodeChartEl" class="plotly-chart plotly-chart-tall"></div> |
217 | 228 | <p v-if="!featureStats" class="info-text">Upload datasets and run filtering to see barcode.</p> |
218 | 229 | </section> |
219 | 230 |
|
| 231 | + <!-- Class mean heatmap (features x classes) --> |
| 232 | + <section class="section viz-section" v-if="vizTab === 'classHeatmap'"> |
| 233 | + <div class="viz-controls"> |
| 234 | + <label class="toggle-label"> |
| 235 | + <span>{{ $t('data.transform') }}</span> |
| 236 | + <select v-model="dataTransform" @change="loadClassHeatmap"> |
| 237 | + <option value="raw">Raw</option> |
| 238 | + <option value="log">Log</option> |
| 239 | + <option value="zscore">Z-score</option> |
| 240 | + </select> |
| 241 | + </label> |
| 242 | + </div> |
| 243 | + <div ref="classHeatmapChartEl" class="plotly-chart plotly-chart-tall"></div> |
| 244 | + <p v-if="!featureStats" class="info-text">Upload datasets and run filtering to see class heatmap.</p> |
| 245 | + </section> |
| 246 | + |
220 | 247 | <!-- Volcano plot --> |
221 | 248 | <section class="section viz-section" v-if="vizTab === 'volcano'"> |
222 | 249 | <div ref="volcanoChartEl" class="plotly-chart plotly-chart-tall"></div> |
@@ -332,6 +359,7 @@ const summary = ref(null) |
332 | 359 | const featureStats = ref(null) |
333 | 360 | const abundanceData = ref([]) |
334 | 361 | const barcodeData = ref(null) |
| 362 | +const classHeatmapData = ref(null) |
335 | 363 | const mspAnnotations = ref({}) |
336 | 364 | const vizTab = ref('prevalence') |
337 | 365 | const pcoaData = ref(null) |
@@ -374,6 +402,7 @@ const selectedFeatures = ref([]) |
374 | 402 | const prevalenceChartEl = ref(null) |
375 | 403 | const abundanceChartEl = ref(null) |
376 | 404 | const barcodeChartEl = ref(null) |
| 405 | +const classHeatmapChartEl = ref(null) |
377 | 406 | const volcanoChartEl = ref(null) |
378 | 407 | const pcoaChartEl = ref(null) |
379 | 408 |
|
@@ -663,14 +692,31 @@ async function loadBarcodeData() { |
663 | 692 | if (feats.length === 0 || !hasTrainData.value) return |
664 | 693 | try { |
665 | 694 | const { data } = await axios.get(`/api/data-explore/${projectId.value}/barcode-data`, { |
666 | | - params: { features: feats.join(',') }, |
| 695 | + params: { features: feats.join(','), transform: dataTransform.value }, |
667 | 696 | }) |
668 | 697 | barcodeData.value = data |
| 698 | + await nextTick() |
| 699 | + renderBarcodeChart() |
669 | 700 | } catch (e) { |
670 | 701 | console.error('Failed to load barcode data:', e) |
671 | 702 | } |
672 | 703 | } |
673 | 704 |
|
| 705 | +async function loadClassHeatmap() { |
| 706 | + const feats = plotFeatureNames.value |
| 707 | + if (feats.length === 0 || !hasTrainData.value) return |
| 708 | + try { |
| 709 | + const { data } = await axios.get(`/api/data-explore/${projectId.value}/class-heatmap`, { |
| 710 | + params: { features: feats.join(','), transform: dataTransform.value }, |
| 711 | + }) |
| 712 | + classHeatmapData.value = data |
| 713 | + await nextTick() |
| 714 | + renderClassHeatmapChart() |
| 715 | + } catch (e) { |
| 716 | + console.error('Failed to load class heatmap:', e) |
| 717 | + } |
| 718 | +} |
| 719 | +
|
674 | 720 | async function loadPcoaJobs() { |
675 | 721 | try { |
676 | 722 | const { data } = await axios.get(`/api/analysis/${projectId.value}/jobs`) |
@@ -1270,13 +1316,59 @@ function renderCurrentViz() { |
1270 | 1316 | if (vizTab.value === 'prevalence') renderPrevalenceChart() |
1271 | 1317 | else if (vizTab.value === 'abundance') renderAbundanceChart() |
1272 | 1318 | else if (vizTab.value === 'barcode') renderBarcodeChart() |
| 1319 | + else if (vizTab.value === 'classHeatmap') { |
| 1320 | + if (!classHeatmapData.value) loadClassHeatmap() |
| 1321 | + else renderClassHeatmapChart() |
| 1322 | + } |
1273 | 1323 | else if (vizTab.value === 'volcano') renderVolcanoChart() |
1274 | 1324 | else if (vizTab.value === 'pcoa') { |
1275 | 1325 | if (!pcoaData.value && !loadingPcoa.value) loadPcoa() |
1276 | 1326 | else renderPcoaChart() |
1277 | 1327 | } |
1278 | 1328 | } |
1279 | 1329 |
|
| 1330 | +async function renderClassHeatmapChart() { |
| 1331 | + await nextTick() |
| 1332 | + if (!classHeatmapChartEl.value || !classHeatmapData.value || classHeatmapData.value.matrix.length === 0) return |
| 1333 | + const c = chartColors() |
| 1334 | + const d = classHeatmapData.value |
| 1335 | +
|
| 1336 | + // Symmetric divergent colorscale centered at 0 (z-score) or around mean (raw/log) |
| 1337 | + const flat = d.matrix.flat().filter(v => Number.isFinite(v)) |
| 1338 | + let maxAbs = 0 |
| 1339 | + if (flat.length > 0) { |
| 1340 | + maxAbs = Math.max(Math.abs(Math.min(...flat)), Math.abs(Math.max(...flat))) |
| 1341 | + } |
| 1342 | + if (maxAbs === 0) maxAbs = 1 |
| 1343 | +
|
| 1344 | + const isZscore = d.transform === 'zscore' |
| 1345 | + const colorscale = isZscore |
| 1346 | + ? [[0, '#0000b8'], [0.25, '#4472ff'], [0.5, '#f8f8f8'], [0.75, '#ff6060'], [1, '#b80000']] |
| 1347 | + : [[0, '#f8f8f8'], [0.5, '#ffa500'], [1, '#b80000']] |
| 1348 | +
|
| 1349 | + const trace = { |
| 1350 | + z: d.matrix, // (features x classes) |
| 1351 | + x: d.class_labels, |
| 1352 | + y: d.feature_names.map(featureLabel), |
| 1353 | + type: 'heatmap', |
| 1354 | + colorscale, |
| 1355 | + zmin: isZscore ? -maxAbs : Math.min(...flat), |
| 1356 | + zmax: isZscore ? maxAbs : Math.max(...flat), |
| 1357 | + zmid: isZscore ? 0 : undefined, |
| 1358 | + hovertemplate: 'Feature: %{y}<br>Class: %{x}<br>Value: %{z:.3f}<extra></extra>', |
| 1359 | + colorbar: { title: { text: d.transform }, thickness: 12 }, |
| 1360 | + } |
| 1361 | +
|
| 1362 | + const layout = chartLayout({ |
| 1363 | + title: `Class mean heatmap (${d.transform})`, |
| 1364 | + xaxis: { title: 'Class', side: 'bottom' }, |
| 1365 | + yaxis: { title: 'Feature', autorange: 'reversed', tickfont: { size: 10 } }, |
| 1366 | + margin: { l: 220, r: 40, t: 50, b: 60 }, |
| 1367 | + }) |
| 1368 | +
|
| 1369 | + Plotly.newPlot(classHeatmapChartEl.value, [trace], layout, { responsive: true, displayModeBar: true }) |
| 1370 | +} |
| 1371 | +
|
1280 | 1372 | // Watchers for chart rendering |
1281 | 1373 | watch(vizTab, async () => { |
1282 | 1374 | await nextTick() |
|
0 commit comments