Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bedhost-ui-2",
"private": true,
"version": "0.12.4",
"version": "0.12.5",
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description TODOs mention updating pepdbagent __version__.py and the changelog, but this PR only shows UI changes (UMAP logic + UI version bump). Either update the PR description to match or include the missing version/changelog changes so the release notes are accurate.

Copilot uses AI. Check for mistakes.
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/umap/bed-embedding-plot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import toast from 'react-hot-toast';
import * as vg from '@uwdata/vgplot';

import { tableau20 } from '../../utils';

const categoryColors = [...tableau20, '#cccccc'];
import { AtlasTooltip } from './atlas-tooltip';
import { useMosaicCoordinator } from '../../contexts/mosaic-coordinator-context';

Expand Down Expand Up @@ -157,7 +159,7 @@ export const BEDEmbeddingPlot = forwardRef<BEDEmbeddingPlotRef, Props>((props, r
identifier='id'
text='name'
category={colorGrouping}
categoryColors={tableau20}
categoryColors={categoryColors}
additionalFields={{ Description: 'description', Assay: 'assay', 'Cell Line': 'cell_line' }}
height={height || 500}
width={containerWidth}
Expand Down
79 changes: 60 additions & 19 deletions ui/src/components/umap/bed-embedding-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const BEDEmbeddingView = (props: Props) => {
const [selectedPoints, setSelectedPoints] = useState<any[]>([]);
const [initialPoint, setInitialPoint] = useState<any>(null);
const [viewportState, setViewportState] = useState<any>(null);
const [legendItems, setLegendItems] = useState<string[]>([]);
const [legendItems, setLegendItems] = useState<any[]>([]);
const [filterSelection, setFilterSelection] = useState<any>(null);
const [addedToCart, setAddedToCart] = useState(false);
const [tooltipPoint, setTooltipPoint] = useState<any>(null);
Expand All @@ -46,6 +46,18 @@ export const BEDEmbeddingView = (props: Props) => {
const [pinnedCategories, setPinnedCategories] = useState<Set<any>>(new Set());
const [pinGrouping, setPinGrouping] = useState<string>(colorGrouping);

const categoryColors = useMemo(() => {
const colors = [...tableau20, '#cccccc'];
if (legendItems) {
legendItems.forEach((item: any) => {
if (item.name === 'UNKNOWN' && item.category < 20) {
colors[item.category] = '#cccccc';
}
});
}
return colors;
}, [legendItems]);

const filter = useMemo(() => vg.Selection.intersect(), []);
const legendFilterSource = useMemo(() => ({}), []);
const neighborIDs = useMemo(() => neighbors?.results?.map((result) => result.id), [neighbors]);
Expand Down Expand Up @@ -350,11 +362,15 @@ export const BEDEmbeddingView = (props: Props) => {
};

const fetchLegendItems = async (coordinator: any) => {
const query = `SELECT DISTINCT
${colorGrouping.replace('_category', '')} as name,
${colorGrouping} as category
FROM data
ORDER BY ${colorGrouping}`;
const fieldName = colorGrouping.replace('_category', '');
const query = `
SELECT
CASE WHEN ${colorGrouping} < 20 THEN ${fieldName} ELSE 'Other' END as name,
CASE WHEN ${colorGrouping} < 20 THEN ${colorGrouping} ELSE 20 END as category,
COUNT(*) as count
FROM data
GROUP BY 1, 2
ORDER BY count DESC`;

const result = (await coordinator.query(query, { type: 'json' })) as any[];
return result;
Expand Down Expand Up @@ -546,7 +562,7 @@ export const BEDEmbeddingView = (props: Props) => {
identifier='id'
text='name'
category={colorGrouping}
categoryColors={tableau20}
categoryColors={categoryColors}
additionalFields={{ Description: 'description', Assay: 'assay', 'Cell Line': 'cell_line' }}
height={embeddingHeight}
width={containerWidth}
Expand Down Expand Up @@ -641,18 +657,40 @@ export const BEDEmbeddingView = (props: Props) => {
</label>
</div>
</div>
{pinnedCategories.size > 0 && (
{legendItems?.length > 0 && (
<div className='card-header border-bottom py-1 px-2 d-flex justify-content-between align-items-center'>
<span className='text-xs text-muted'>{pinnedCategories.size} pinned</span>
<button
className='btn btn-outline-danger btn-xs'
onClick={() => {
setPinnedCategories(new Set());
filter.update({ source: legendFilterSource, value: null, predicate: null });
}}
>
Unpin All
</button>
{pinnedCategories.size > 0 && (
<span className='text-xs text-muted'>{pinnedCategories.size} pinned</span>
)}
<span className='d-flex gap-1 ms-auto'>
{pinnedCategories.size < legendItems.length && (
<button
className='btn btn-outline-primary btn-xs'
onClick={() => {
const allCategories = new Set(legendItems.map((item: any) => item.category));
setPinnedCategories(allCategories);
setPinGrouping(colorGrouping);
const categories = Array.from(allCategories);
const predicate = vg.or(...categories.map((cat: any) => vg.eq(colorGrouping, cat)));
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pin All builds the filter predicate with vg.or(...) unconditionally. Elsewhere in this file, single-category cases are handled with vg.eq(...) to avoid relying on vg.or behavior with a single argument. Update Pin All to mirror that logic so pinning works correctly when the legend only contains one category.

Suggested change
const predicate = vg.or(...categories.map((cat: any) => vg.eq(colorGrouping, cat)));
const predicates = categories.map((cat: any) => vg.eq(colorGrouping, cat));
const predicate =
predicates.length === 1 ? predicates[0] : vg.or(...predicates);

Copilot uses AI. Check for mistakes.
filter.update({ source: legendFilterSource, value: categories, predicate });
setFilterSelection(null);
}}
>
Pin All
</button>
)}
{pinnedCategories.size > 0 && (
<button
className='btn btn-outline-danger btn-xs'
onClick={() => {
setPinnedCategories(new Set());
filter.update({ source: legendFilterSource, value: null, predicate: null });
}}
>
Unpin All
</button>
)}
</span>
</div>
)}
<div className='card-body table-responsive p-0'>
Expand All @@ -669,8 +707,11 @@ export const BEDEmbeddingView = (props: Props) => {
>
<td className='d-flex justify-content-between align-items-center' style={{ height: '30px' }}>
<span>
<i className='bi bi-square-fill me-3' style={{ color: tableau20[item.category] }} />
<i className='bi bi-square-fill me-3' style={{ color: item.name === 'UNKNOWN' ? '#cccccc' : categoryColors[item.category] }} />
{item.name}
{item.count != null && (
<span className='text-muted ms-1'>({Number(item.count).toLocaleString()})</span>
)}
</span>
<span className='d-flex align-items-center gap-1'>
{/*{isFiltered && <button className='btn btn-danger btn-xs'>Clear</button>}*/}
Expand Down
1 change: 1 addition & 0 deletions ui/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const API_BASE = import.meta.env.VITE_API_BASE || '';
const EXAMPLE_URL = `${API_BASE}/bed/example`;

export const UMAP_URL = 'https://huggingface.co/databio/bedbase-umap/resolve/main/hg38_umap_3_13.json';
// export const UMAP_URL = `${window.location.origin}/feb08_3_13.json`;
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid committing commented-out alternative UMAP_URL values (especially ones that reference window.location). If you need an override for local testing, prefer an env var (e.g. VITE_UMAP_URL) or remove this line before release to prevent confusion about the canonical data source.

Suggested change
// export const UMAP_URL = `${window.location.origin}/feb08_3_13.json`;

Copilot uses AI. Check for mistakes.

export const BEDBASE_PYTHON_CODE_MD = `
\`\`\`python
Expand Down
37 changes: 18 additions & 19 deletions ui/src/contexts/mosaic-coordinator-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,22 @@ export const MosaicCoordinatorProvider = ({ children }: { children: ReactNode })
unnest(nodes, recursive := true)
FROM read_json_auto('${UMAP_URL}')`,
vg.sql`CREATE OR REPLACE TABLE data AS
SELECT
*,
(DENSE_RANK() OVER (ORDER BY assay) - 1)::INTEGER AS assay_category,
(DENSE_RANK() OVER (ORDER BY cell_line) - 1)::INTEGER AS cell_line_category
FROM data` as any,
WITH assay_counts AS (
SELECT assay,
(ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) - 1)::INTEGER as rank
FROM data GROUP BY assay
),
cell_line_counts AS (
SELECT cell_line,
(ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) - 1)::INTEGER as rank
FROM data GROUP BY cell_line
)
SELECT d.*,
CASE WHEN ac.rank < 20 THEN ac.rank ELSE 20 END AS assay_category,
CASE WHEN cc.rank < 20 THEN cc.rank ELSE 20 END AS cell_line_category
FROM data d
JOIN assay_counts ac ON d.assay = ac.assay
JOIN cell_line_counts cc ON d.cell_line = cc.cell_line` as any,
Comment on lines +56 to +60
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The categorization query uses inner joins on assay and cell_line. If either column can be NULL in the UMAP JSON, rows with NULL values will be dropped because NULL = NULL does not match, causing points to disappear from the embedding. Consider using LEFT JOIN plus a default rank/category, or join with IS NOT DISTINCT FROM to preserve NULL-valued rows.

Suggested change
CASE WHEN ac.rank < 20 THEN ac.rank ELSE 20 END AS assay_category,
CASE WHEN cc.rank < 20 THEN cc.rank ELSE 20 END AS cell_line_category
FROM data d
JOIN assay_counts ac ON d.assay = ac.assay
JOIN cell_line_counts cc ON d.cell_line = cc.cell_line` as any,
CASE
WHEN ac.rank IS NULL OR ac.rank >= 20 THEN 20
ELSE ac.rank
END AS assay_category,
CASE
WHEN cc.rank IS NULL OR cc.rank >= 20 THEN 20
ELSE cc.rank
END AS cell_line_category
FROM data d
LEFT JOIN assay_counts ac ON d.assay IS NOT DISTINCT FROM ac.assay
LEFT JOIN cell_line_counts cc ON d.cell_line IS NOT DISTINCT FROM cc.cell_line` as any,

Copilot uses AI. Check for mistakes.
]);

dataInitializedRef.current = true;
Expand All @@ -63,18 +74,6 @@ export const MosaicCoordinatorProvider = ({ children }: { children: ReactNode })

await coordinator.exec([vg.sql`DELETE FROM data WHERE id = 'custom_point'` as any]);

// Get max category indices for uploaded points (after deletion to ensure clean state)
const maxCategories = (await coordinator.query(
`SELECT
MAX(assay_category) as max_assay_category,
MAX(cell_line_category) as max_cell_line_category
FROM data`,
{ type: 'json' },
)) as any[];

const assayCategory = (maxCategories[0]?.max_assay_category ?? -1) + 1;
const cellLineCategory = (maxCategories[0]?.max_cell_line_category ?? -1) + 1;

await coordinator.exec([
vg.sql`INSERT INTO data VALUES (
${x},
Expand All @@ -84,8 +83,8 @@ export const MosaicCoordinatorProvider = ({ children }: { children: ReactNode })
'${description}',
'Uploaded BED',
'Uploaded BED',
${assayCategory},
${cellLineCategory}
20,
20
)` as any,
]);
};
Expand Down
Loading