Skip to content
Closed
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
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
],
"scripts": {
"build": "./scripts/build.sh",
"test": "npm run test -w @embedding-atlas/density-clustering -w @embedding-atlas/umap-wasm -w @embedding-atlas/utils",
"test": "npm run test -w @embedding-atlas/density-clustering -w @embedding-atlas/umap-wasm -w @embedding-atlas/utils -w @embedding-atlas/viewer",
"check": "npm run check -w @embedding-atlas/component -w @embedding-atlas/table -w @embedding-atlas/viewer -w @embedding-atlas/embedding-atlas -w @embedding-atlas/examples -w @embedding-atlas/backend",
"check-format": "prettier -c . && uvx ruff check && uvx ruff format --check --exclude '*.ipynb'"
},
Expand All @@ -25,5 +25,6 @@
},
"dependencies": {
"svelte": "^5.43.3"
}
},
"packageManager": "[email protected]+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67"
}
4 changes: 3 additions & 1 deletion packages/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "vite",
"build": "uv run python scripts/download_duckdb_extensions.py && vite build && vite build --config vite.config.lib.js",
"check": "svelte-check",
"test": "vitest run",
"package": "npm run build && publint",
"preview": "vite preview"
},
Expand Down Expand Up @@ -71,6 +72,7 @@
"unplugin-icons": "^22.2.0",
"vite": "^7.0.6",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-wasm": "^3.5.0"
"vite-plugin-wasm": "^3.5.0",
"vitest": "^4.0.9"
}
}
37 changes: 30 additions & 7 deletions packages/viewer/src/charts/basic/CountPlot.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { computeFieldStats, inferAggregate, type AggregateInfo, type FieldStats } from "../common/aggregate.js";
import { resolveChartTheme } from "../common/theme.js";
import type { CountPlotSpec } from "./types.js";
import { sortItems } from "./sortItems.js";

interface State {
selection?: string[];
Expand All @@ -30,15 +31,15 @@
}: ChartViewProps<CountPlotSpec, State> = $props();
let { coordinator, colorScheme, theme: themeConfig } = context;
let { selection } = $derived(chartState);
let { expanded, percentage } = $derived(spec);
let { expanded, percentage, sortBy, sortOrder } = $derived(spec);

interface Bin {
x: string;
count: number;
}

interface ChartData {
items: { x: string; total: number; selected: number }[];
items: { x: string; total: number; selected: number; isSpecial: boolean }[];
sumTotal: number;
sumSelected: number;
firstSpecialIndex: number;
Expand All @@ -48,7 +49,9 @@
let chartData = $state.raw<ChartData | undefined>(undefined);
let chartWidth = $state.raw(400);

let maxCount = $derived(chartData?.items.reduce((a, b) => Math.max(a, percentage ? b.selected : b.total), 0) ?? 0);
let sortedItems = $derived(chartData ? sortItems(chartData.items, chartData.firstSpecialIndex, sortBy, sortOrder) : []);

let maxCount = $derived(sortedItems.reduce((a, b) => Math.max(a, percentage ? b.selected : b.total), 0));
let xScale = $derived(d3.scaleLinear([0, Math.max(1, maxCount)], [0, chartWidth - 250]));

// Adjust scale so the minimum width for non-zero count is 1px.
Expand Down Expand Up @@ -120,11 +123,13 @@

if (allItems.every((d) => typeof d.x == "string")) {
let specialValues = capturedAggregate.scale.specialValues ?? [];
let specialValuesSet = new Set(specialValues);
let hasOther = specialValues.filter((x) => x != "(null)").length > 0;
let items = [...capturedAggregate.scale.domain, ...specialValues].map((d) => ({
x: d,
total: mapTotal.get(keyfunc(d)) ?? 0,
selected: mapSelected.get(keyfunc(d)) ?? 0,
isSpecial: specialValuesSet.has(d),
}));
let sumTotal = items.reduce((a, b) => a + b.total, 0);
let sumSelected = items.reduce((a, b) => a + b.selected, 0);
Expand All @@ -146,6 +151,7 @@
x: d,
total: mapTotal.get(keyfunc(d)) ?? 0,
selected: mapSelected.get(keyfunc(d)) ?? 0,
isSpecial: typeof d == "string",
}));
let sumTotal = items.reduce((a, b) => a + b.total, 0);
let sumSelected = items.reduce((a, b) => a + b.selected, 0);
Expand Down Expand Up @@ -234,11 +240,12 @@
<Container width={width} height={height} scrollY={true}>
<div class="flex flex-col text-sm w-full select-none" bind:clientWidth={chartWidth}>
{#if chartData}
{#each chartData.items as bar, i}
{#each sortedItems as bar, i}
{@const selected =
selection == undefined || selection.length == 0 || selection.findIndex((x) => isSame(x, bar.x)) >= 0}
{@const hasSelection = !chartData.items.every((x) => x.total == x.selected)}
{#if i == chartData.firstSpecialIndex}
{@const hasSelection = !sortedItems.every((x) => x.total == x.selected)}
{@const prevBar = i > 0 ? sortedItems[i - 1] : null}
{#if bar.isSpecial && prevBar && !prevBar.isSpecial}
<hr class="mt-1 mb-1 border-slate-300 dark:border-slate-500 border-dashed" />
{/if}
<button
Expand Down Expand Up @@ -326,7 +333,23 @@
{/if}
</div>

<div class="flex">
<div class="flex gap-1">
<InlineSelect
options={[
{ value: "desc", label: "↓" },
{ value: "asc", label: "↑" },
]}
value={sortOrder ?? "desc"}
onChange={(v) => onSpecChange({ sortOrder: v as "asc" | "desc" })}
/>
<InlineSelect
options={[
{ value: "total", label: "all" },
{ value: "selected", label: "sel" },
]}
value={sortBy ?? "total"}
onChange={(v) => onSpecChange({ sortBy: v as "total" | "selected" })}
/>
<InlineSelect
options={[
{ value: "true", label: "%" },
Expand Down
42 changes: 42 additions & 0 deletions packages/viewer/src/charts/basic/sortItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) 2025 Apple Inc. Licensed under MIT License.

export interface SortableItem {
x: string;
total: number;
selected: number;
isSpecial: boolean;
}

/**
* Sorts items for display in count plots.
* Regular items are sorted by the specified field and order.
* Special items (null, other) are kept at the end, unsorted.
*
* @param items - Array of items to sort
* @param firstSpecialIndex - Index where special items start (-1 if no special items)
* @param sortBy - Field to sort by ("total" or "selected"), defaults to "total"
* @param sortOrder - Sort order ("asc" or "desc"), defaults to "desc"
* @returns Sorted array with regular items sorted and special items at the end
*/
export function sortItems(
items: SortableItem[],
firstSpecialIndex: number,
sortBy: "total" | "selected" | undefined,
sortOrder: "asc" | "desc" | undefined,
): SortableItem[] {
// Split into regular items and special items using slice (more efficient than filter)
let splitIndex = firstSpecialIndex === -1 ? items.length : firstSpecialIndex;
let regularItems = items.slice(0, splitIndex);
let specialItems = items.slice(splitIndex);

// Sort regular items
let sortField = sortBy ?? "total";
let order = sortOrder ?? "desc";
regularItems = regularItems.slice().sort((a, b) => {
let diff = a[sortField] - b[sortField];
return order === "asc" ? diff : -diff;
});

// Special items stay at the end, unsorted
return [...regularItems, ...specialItems];
}
4 changes: 4 additions & 0 deletions packages/viewer/src/charts/basic/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export interface CountPlotSpec {
};
expanded?: boolean;
percentage?: boolean;
/** Sort by total count or selected (filtered) count. Default: "total" */
sortBy?: "total" | "selected";
/** Sort order. Default: "desc" */
sortOrder?: "asc" | "desc";
}

export interface PredicatesSpec {
Expand Down
143 changes: 143 additions & 0 deletions packages/viewer/test/sortItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) 2025 Apple Inc. Licensed under MIT License.

import { describe, expect, test } from "vitest";
import { sortItems, type SortableItem } from "../src/charts/basic/sortItems.js";

describe("sortItems", () => {
// Helper to create test items
function createItem(x: string, total: number, selected: number, isSpecial = false): SortableItem {
return { x, total, selected, isSpecial };
}

describe("sorting regular items", () => {
test("sorts by total descending by default", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 15),
createItem("c", 20, 10),
];
let result = sortItems(items, -1, undefined, undefined);

expect(result.map((i) => i.x)).toEqual(["b", "c", "a"]);
});

test("sorts by total ascending", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 15),
createItem("c", 20, 10),
];
let result = sortItems(items, -1, "total", "asc");

expect(result.map((i) => i.x)).toEqual(["a", "c", "b"]);
});

test("sorts by selected descending", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 3),
createItem("c", 20, 10),
];
let result = sortItems(items, -1, "selected", "desc");

expect(result.map((i) => i.x)).toEqual(["c", "a", "b"]);
});

test("sorts by selected ascending", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 3),
createItem("c", 20, 10),
];
let result = sortItems(items, -1, "selected", "asc");

expect(result.map((i) => i.x)).toEqual(["b", "a", "c"]);
});
});

describe("handling special items", () => {
test("keeps special items at the end after sorting", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 15),
createItem("c", 20, 10),
createItem("(null)", 5, 2, true),
createItem("(5 others)", 8, 4, true),
];
let result = sortItems(items, 3, "total", "desc");

expect(result.map((i) => i.x)).toEqual(["b", "c", "a", "(null)", "(5 others)"]);
});

test("does not sort special items", () => {
let items = [
createItem("a", 10, 5),
createItem("(5 others)", 100, 50, true),
createItem("(null)", 5, 2, true),
];
let result = sortItems(items, 1, "total", "desc");

// Special items stay in their original order even though (5 others) has higher total
expect(result.map((i) => i.x)).toEqual(["a", "(5 others)", "(null)"]);
});

test("handles array with only special items", () => {
let items = [
createItem("(null)", 5, 2, true),
createItem("(5 others)", 8, 4, true),
];
let result = sortItems(items, 0, "total", "desc");

expect(result.map((i) => i.x)).toEqual(["(null)", "(5 others)"]);
});

test("handles firstSpecialIndex = -1 (no special items)", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 15),
createItem("c", 20, 10),
];
let result = sortItems(items, -1, "total", "desc");

expect(result.map((i) => i.x)).toEqual(["b", "c", "a"]);
});
});

describe("edge cases", () => {
test("handles empty array", () => {
let result = sortItems([], -1, "total", "desc");
expect(result).toEqual([]);
});

test("handles single item", () => {
let items = [createItem("a", 10, 5)];
let result = sortItems(items, -1, "total", "desc");

expect(result).toEqual(items);
});

test("handles items with equal values", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 10, 5),
createItem("c", 10, 5),
];
let result = sortItems(items, -1, "total", "desc");

expect(result.length).toBe(3);
expect(result.every((i) => i.total === 10)).toBe(true);
});

test("does not mutate original array", () => {
let items = [
createItem("a", 10, 5),
createItem("b", 30, 15),
createItem("c", 20, 10),
];
let originalOrder = items.map((i) => i.x);
sortItems(items, -1, "total", "desc");

expect(items.map((i) => i.x)).toEqual(originalOrder);
});
});
});
11 changes: 11 additions & 0 deletions packages/viewer/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "node",
globals: true,
testTimeout: 1000,
include: ["test/**/*.test.{js,ts}"],
exclude: ["node_modules/**/*"],
},
});
Loading