diff --git a/bedhost/_version.py b/bedhost/_version.py index 8e1395bd..6dd4954d 100644 --- a/bedhost/_version.py +++ b/bedhost/_version.py @@ -1 +1 @@ -__version__ = "0.12.3" +__version__ = "0.12.4" diff --git a/bedhost/routers/bed_api.py b/bedhost/routers/bed_api.py index b90b20e7..b53c9c05 100644 --- a/bedhost/routers/bed_api.py +++ b/bedhost/routers/bed_api.py @@ -467,12 +467,18 @@ async def text_to_bed_search( score=1.0, metadata=bbagent.bed.get(query), ) + if result.metadata is None: + raise BEDFileNotFoundError(f"Bed file with id {query} not found") + try: similar_results = bbagent.bed.get_neighbours( query, limit=limit, offset=offset ) if similar_results.results and offset == 0: similar_results.results.insert(0, result) + return similar_results + else: + raise BEDFileNotFoundError(f"Similar beds not found") except Exception as _: similar_results = BedListSearchResult( count=1, diff --git a/requirements/requirements-all.txt b/requirements/requirements-all.txt index b4602c17..6af541d8 100644 --- a/requirements/requirements-all.txt +++ b/requirements/requirements-all.txt @@ -1,5 +1,5 @@ # bbconf @ git+https://github.com/databio/bbconf.git@dev#egg=bbconf -bbconf>=0.14.2 +bbconf>=0.14.4 fastapi>=0.103.0 logmuse>=0.2.7 markdown diff --git a/ui/package.json b/ui/package.json index 0e3044a3..dcd1c3ea 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "bedhost-ui-2", "private": true, - "version": "0.12.2", + "version": "0.12.4", "type": "module", "scripts": { "dev": "vite", diff --git a/ui/src/components/bed-splash-components/header.tsx b/ui/src/components/bed-splash-components/header.tsx index c705f9f3..92391ffe 100644 --- a/ui/src/components/bed-splash-components/header.tsx +++ b/ui/src/components/bed-splash-components/header.tsx @@ -213,7 +213,7 @@ export const BedSplashHeader = (props: Props) => { > {metadata?.genome_digest ? ( <> -
diff --git a/ui/src/components/umap/atlas-tooltip.tsx b/ui/src/components/umap/atlas-tooltip.tsx index d0329962..5db95290 100644 --- a/ui/src/components/umap/atlas-tooltip.tsx +++ b/ui/src/components/umap/atlas-tooltip.tsx @@ -16,7 +16,7 @@ interface TooltipProps { showLink?: boolean; } -const TooltipContent = ({ tooltip, showLink }: { tooltip: TooltipProps['tooltip']; showLink?: boolean }) => { +const TooltipContent = ({ tooltip }: { tooltip: TooltipProps['tooltip']; showLink?: boolean }) => { if (!tooltip) return null; return (
)} - {showLink && tooltip.identifier !== 'custom_point' && ( - - Go! - - )} + {/*{showLink && tooltip.identifier !== 'custom_point' && (*/} + {/* */} + {/* Go!*/} + {/* */} + {/*)}*/}
); diff --git a/ui/src/components/umap/bed-embedding-view.tsx b/ui/src/components/umap/bed-embedding-view.tsx index 1e5ee3db..fbf3e67b 100644 --- a/ui/src/components/umap/bed-embedding-view.tsx +++ b/ui/src/components/umap/bed-embedding-view.tsx @@ -8,6 +8,7 @@ import { components } from '../../../bedbase-types'; import { AtlasTooltip } from './atlas-tooltip'; import { useMosaicCoordinator } from '../../contexts/mosaic-coordinator-context'; import { useBedUmap } from '../../queries/useBedUmap'; +import { EmbeddingStats } from './embedding-stats'; type SearchResponse = components['schemas']['BedListSearchResult']; @@ -42,6 +43,8 @@ export const BEDEmbeddingView = (props: Props) => { const [dataVersion, setDataVersion] = useState(0); const [pendingSelection, setPendingSelection] = useState(null); const [uploadButtonText, setUploadButtonText] = useState('Upload BED'); + const [pinnedCategories, setPinnedCategories] = useState>(new Set()); + const [pinGrouping, setPinGrouping] = useState(colorGrouping); const filter = useMemo(() => vg.Selection.intersect(), []); const legendFilterSource = useMemo(() => ({}), []); @@ -171,6 +174,45 @@ export const BEDEmbeddingView = (props: Props) => { } }; + const handlePinToggle = (item: any) => { + setPinnedCategories((prev) => { + // If pinning in a different grouping than existing pins, start fresh + if (prev.size > 0 && pinGrouping !== colorGrouping) { + const next = new Set([item.category]); + setPinGrouping(colorGrouping); + filter.update({ + source: legendFilterSource, + value: [item.category], + predicate: vg.eq(colorGrouping, item.category), + }); + setFilterSelection(null); + return next; + } + + const next = new Set(prev); + if (next.has(item.category)) { + next.delete(item.category); + } else { + next.add(item.category); + setPinGrouping(colorGrouping); + } + + if (next.size === 0) { + setFilterSelection(null); + filter.update({ source: legendFilterSource, value: null, predicate: null }); + } else { + const categories = Array.from(next); + const predicate = + categories.length === 1 + ? vg.eq(colorGrouping, categories[0]) + : vg.or(...categories.map((cat) => vg.eq(colorGrouping, cat))); + filter.update({ source: legendFilterSource, value: categories, predicate }); + setFilterSelection(null); + } + return next; + }); + }; + const handlePointSelection = (dataPoints: any[] | null) => { // console.log('Selection changed via onSelection callback:', dataPoints); const points = dataPoints || []; @@ -215,8 +257,14 @@ export const BEDEmbeddingView = (props: Props) => { let result; - // filter clause prevents selecting points that are not within a selected legend category - const filterClause = filterSelection ? ` AND ${colorGrouping} = '${filterSelection.category}'` : ''; + // filter clause prevents selecting points that are not within a selected legend category or pinned categories + let filterClause = ''; + if (filterSelection) { + filterClause = ` AND ${colorGrouping} = '${filterSelection.category}'`; + } else if (pinnedCategories.size > 0) { + const pinList = Array.from(pinnedCategories).map((c) => `'${c}'`).join(','); + filterClause = ` AND ${pinGrouping} IN (${pinList})`; + } // Check if rectangle selection (bounding box) if (typeof value === 'object' && 'xMin' in value) { @@ -337,12 +385,24 @@ export const BEDEmbeddingView = (props: Props) => { }, [isReady]); useEffect(() => { - // set legend items - if (isReady) { - fetchLegendItems(coordinator).then((result) => { - setLegendItems(result); - }); - } + if (!isReady) return; + + const refresh = async () => { + const newLegend = await fetchLegendItems(coordinator); + setLegendItems(newLegend); + + // Re-apply pin filter using the ORIGINAL pinGrouping column + if (pinnedCategories.size > 0) { + const categories = Array.from(pinnedCategories); + const predicate = + categories.length === 1 + ? vg.eq(pinGrouping, categories[0]) + : vg.or(...categories.map((cat) => vg.eq(pinGrouping, cat))); + filter.update({ source: legendFilterSource, value: categories, predicate }); + } + }; + + refresh(); }, [isReady, colorGrouping]); useEffect(() => { @@ -514,12 +574,13 @@ export const BEDEmbeddingView = (props: Props) => {
- + + @@ -530,9 +591,16 @@ export const BEDEmbeddingView = (props: Props) => { key={point.identifier + '_' + index} > - - - + + + + ))} @@ -541,7 +609,7 @@ export const BEDEmbeddingView = (props: Props) => {
-
+
Legend
@@ -573,30 +641,62 @@ export const BEDEmbeddingView = (props: Props) => {
+ {pinnedCategories.size > 0 && ( +
+ {pinnedCategories.size} pinned + +
+ )}
BED Name Assay Cell Line Description
{point.text}{point.fields.Assay}{point.fields['Cell Line']}{point.fields.Description}{point.fields?.Assay}{point.fields?.['Cell Line']}{point.fields?.Description} e.stopPropagation()}> + {point.identifier !== 'custom_point' && ( + + View + + )} +
- {legendItems?.map((item: any) => ( - handleLegendClick(item)} - key={item.category} - > - - - ))} + {legendItems?.map((item: any) => { + const isPinned = colorGrouping === pinGrouping && pinnedCategories.has(item.category); + const isFiltered = filterSelection?.category === item.category; + return ( + handleLegendClick(item)} + key={item.category} + > + + + ); + })}
- - - {item.name} - - {filterSelection?.category === item.category && ( - - )} -
+ + + {item.name} + + + {/*{isFiltered && }*/} + { + e.stopPropagation(); + handlePinToggle(item); + }} + /> + +
+ ) : ( diff --git a/ui/src/components/umap/embedding-stats.tsx b/ui/src/components/umap/embedding-stats.tsx new file mode 100644 index 00000000..10830a8b --- /dev/null +++ b/ui/src/components/umap/embedding-stats.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useMosaicCoordinator } from '../../contexts/mosaic-coordinator-context'; + +type Props = { + selectedPoints: any[]; + colorGrouping: string; + legendItems: any[]; + filterSelection: any; +}; + +export const EmbeddingStats = (props: Props) => { + const { selectedPoints, colorGrouping, legendItems, filterSelection } = props; + const { coordinator } = useMosaicCoordinator(); + + const [totalCounts, setTotalCounts] = useState>(new Map()); + + useEffect(() => { + if (!coordinator || legendItems.length === 0) return; + const query = `SELECT ${colorGrouping} as category, COUNT(*) as count FROM data GROUP BY ${colorGrouping}`; + coordinator + .query(query, { type: 'json' }) + .then((result: any) => { + const map = new Map(); + for (const row of result) { + map.set(row.category, Number(row.count)); + } + setTotalCounts(map); + }) + .catch(() => {}); + }, [coordinator, colorGrouping, legendItems.length]); + + const hasSelection = selectedPoints.length > 0; + + const rows = useMemo(() => { + if (hasSelection) { + const counts = new Map(); + for (const p of selectedPoints) { + const cat = p[colorGrouping] ?? p.category; + if (cat != null) { + counts.set(cat, (counts.get(cat) || 0) + 1); + } + } + return legendItems.map((item) => ({ + name: item.name, + category: item.category, + count: counts.get(item.category) || 0, + })); + } + if (filterSelection) { + return legendItems.map((item) => ({ + name: item.name, + category: item.category, + count: item.category === filterSelection.category ? (totalCounts.get(item.category) ?? 0) : 0, + })); + } + return legendItems.map((item) => ({ + name: item.name, + category: item.category, + count: totalCounts.get(item.category) ?? 0, + })); + }, [selectedPoints, colorGrouping, legendItems, hasSelection, totalCounts, filterSelection]); + + const showBackground = hasSelection || !!filterSelection; + const maxTotal = useMemo( + () => Math.max(1, ...legendItems.map((item) => totalCounts.get(item.category) ?? 0)), + [legendItems, totalCounts], + ); + const maxRows = useMemo(() => Math.max(1, ...rows.map((r) => r.count)), [rows]); + const maxCount = showBackground ? maxTotal : maxRows; + + return ( +
+
Selection Count
+
+
+ {rows.map((row) => { + const total = totalCounts.get(row.category) ?? 0; + return ( +
+ + {row.name} + +
+ {showBackground && total > 0 && ( +
+ )} + {row.count > 0 && ( +
+ )} +
+ + {row.count} / {total} + +
+ ); + })} +
+
+
+ ); +}; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 443bb22f..6369723d 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ build: { target: 'esnext', // Ensure ESNext target for WASM support }, + worker: { + format: 'es', // Use ES module format for workers (required for dynamic imports in workers) + plugins: () => [wasm(), topLevelAwait()], + }, server: { fs: { allow: ['..'], // Allow serving files from one level up to the project root