Skip to content

Commit e61b2bd

Browse files
committed
Add browserless figure export
1 parent 02de139 commit e61b2bd

29 files changed

Lines changed: 1839 additions & 47 deletions

.agents/skills/hyperview-cli/SKILL.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: hyperview-cli
3-
description: Use HyperView's control-plane CLI for hyperview serve, dataset create, workspace create, embeddings compute, layouts compute, runtime jobs, ui layout set, ui selection set, ui panel add, extension add, tools run, native module panels, backend tools, and local HyperView plugin workflows.
3+
description: Use HyperView's control-plane CLI for hyperview serve, dataset create, workspace create, embeddings compute, layouts compute, browserless paper figure export, runtime jobs, ui layout set, ui selection set, ui panel add, extension add, tools run, native module panels, backend tools, and local HyperView plugin workflows.
44
license: MIT
55
compatibility: Requires Python 3.10-3.13 and the hyperview CLI (`uv tool install --python 3.12 hyperview`). Runtime-control commands require a running HyperView server.
66
metadata:
@@ -29,6 +29,7 @@ HyperView currently supports Python 3.10 through 3.13; `--python 3.12` keeps the
2929
- Start or control a running HyperView runtime.
3030
- Register a custom embedding provider.
3131
- Compute embeddings or layouts without restarting the UI.
32+
- Export paper-ready static 3D embedding figures without a browser or Node runtime.
3233
- Switch the active workspace, layout, or selection in a running session.
3334
- Add or remove agent-authored native module panels from local files.
3435
- Create, install, reload, or test a local plugin/extension with Python backend tools and a frontend panel.
@@ -41,7 +42,8 @@ HyperView currently supports Python 3.10 through 3.13; `--python 3.12` keeps the
4142
4. Register a provider if needed.
4243
5. Submit embedding or layout jobs through the runtime.
4344
6. Use `hyperview ui ...` commands to switch what the live UI shows.
44-
7. For plugins, create an extension folder and install it into the running workspace.
45+
7. Export paper figures with `hyperview figure export` when the user needs screenshots or publication diagrams.
46+
8. For plugins, create an extension folder and install it into the running workspace.
4547

4648
## Current model
4749

@@ -54,8 +56,10 @@ HyperView currently supports Python 3.10 through 3.13; `--python 3.12` keeps the
5456
- Plugins are repo-local extension folders with `extension.toml`, optional Python tools, and optional native panel modules.
5557
- Plugin panels call backend tools through `HyperViewPanelSDK.hooks.useTool()` or `hyperview tools run`.
5658
- In practice, create datasets and workspaces before starting the runtime for that workspace. The current runtime loads workspace registry state on startup.
59+
- `figure export` is browserless and supports 3D layouts only. It reuses the persisted 3D camera for the layout when available, otherwise it chooses a paper-oriented default view.
60+
- Paper figure defaults are square, white-background, opaque PNGs with a faint sphere guide and direct labels for small label sets.
5761

58-
Read [references/commands.md](references/commands.md) for command recipes covering datasets, workspaces, providers, embeddings, layouts, runtime UI state, selections, and jobs.
62+
Read [references/commands.md](references/commands.md) for command recipes covering datasets, workspaces, providers, embeddings, layouts, paper figures, runtime UI state, selections, and jobs.
5963
Read [references/native-panels.md](references/native-panels.md) when the task involves authoring or registering a custom panel.
6064
Read [references/plugins.md](references/plugins.md) when the task involves backend-plus-frontend plugins/extensions.
6165

@@ -75,6 +79,8 @@ Read [references/plugins.md](references/plugins.md) when the task involves backe
7579
- For provider args, use repeated `--provider-arg key=value` flags.
7680
- Treat the workspace as the durable unit. Changing datasets means setting a new workspace dataset, not switching among many datasets inside one workspace.
7781
- Prefer native module panels over raw HTML. The panel system no longer relies on iframes.
82+
- For paper diagrams, prefer `hyperview figure export` over browser screenshots unless the user explicitly needs exact UI chrome. It does not require Playwright, browser bundling, or Node at runtime.
83+
- For publication figures, keep the defaults first: `--theme light`, `--guide-style paper`, and `--legend auto`. Use `--show-selection` only when selected samples are meaningful and will be explained in the caption.
7884
- The first `uv run hyperview ...` invocation in a session can take 30+ seconds (torch/datasets imports). Allow generous timeouts and avoid sending SIGINT.
7985

8086
## Inspecting runtime state
@@ -85,4 +91,4 @@ The runtime exposes JSON discovery endpoints alongside the CLI. Use them to obta
8591
- `GET /api/embeddings?workspace_id=<ws>` &mdash; the active or default layout, including `layout_key`, `geometry`, and sample `ids`. Use the returned `layout_key` for `hyperview ui layout set --layout-key ...` and pick from `ids` for `hyperview ui selection set --ids ...`.
8692
- `GET /api/tools` &mdash; registered tool URIs (also returned by `hyperview tools list --json`).
8793

88-
Layout keys encode geometry and dimension as a substring (e.g. `..._euclidean_umap__2d_...`, `..._hyperbolic_umap__3d_...`). Match on those substrings when filtering by geometry/dimension.
94+
Layout keys encode geometry and dimension as a substring (e.g. `..._euclidean_umap__2d_...`, `..._hyperbolic_umap__3d_...`). Match on those substrings when filtering by geometry/dimension.

.agents/skills/hyperview-cli/references/commands.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,63 @@ hyperview jobs list --json
131131
hyperview jobs inspect <job-id> --json
132132
```
133133

134+
## Paper Figures
135+
136+
Export a browserless, paper-ready PNG from the active 3D layout:
137+
138+
```bash
139+
hyperview figure export figures/embedding-sphere.png \
140+
--workspace research \
141+
--layout active \
142+
--json
143+
```
144+
145+
If `--layout` is omitted, HyperView uses the active 3D layout when one is set, otherwise the first available 3D layout. Use `--layout active` when you specifically want the live UI's active layout and want the command to fail if none is active.
146+
147+
The export path is pure Python and does not require Playwright, browser bundling, Node, or a running frontend. It supports 3D layouts only; 2D layouts are rejected with a validation message.
148+
149+
Paper defaults are tuned for academic figures:
150+
151+
- `--width 900 --height 900 --scale 2`
152+
- `--theme light`
153+
- `--guide-style paper`
154+
- `--legend auto` (direct labels for small label sets)
155+
- opaque PNG output
156+
- selection rings hidden unless explicitly requested
157+
158+
Use the 3D view selected in the UI by rotating the scatter panel first. HyperView persists the layout camera and `figure export` reuses it for that layout.
159+
160+
Common variants:
161+
162+
```bash
163+
# Cleanest sphere context: silhouette only.
164+
hyperview figure export figures/embedding-outline.png \
165+
--workspace research \
166+
--layout active \
167+
--guide-style outline
168+
169+
# No sphere guide, useful when the embedding separation is the whole message.
170+
hyperview figure export figures/embedding-clean.png \
171+
--workspace research \
172+
--layout active \
173+
--guide-style none \
174+
--legend direct
175+
176+
# Browser-like guide rings and current selection markers.
177+
hyperview figure export figures/embedding-ui-like.png \
178+
--workspace research \
179+
--layout active \
180+
--guide-style rings \
181+
--legend on \
182+
--show-selection
183+
184+
# Add a short panel title when the figure will stand alone.
185+
hyperview figure export figures/embedding-panel-a.png \
186+
--workspace research \
187+
--layout active \
188+
--title "ArcFace spherical embeddings"
189+
```
190+
134191
## Runtime UI
135192

136193
Discover an existing layout key and sample IDs before mutating runtime state:
@@ -220,4 +277,4 @@ hyperview tools run selection_profile.summarize \
220277
- `--param 'top_k=5'` for numbers
221278
- `--param 'enabled=true'` for booleans
222279
- `--param 'name=foo'` for short strings (raw fallback) or `--param 'name="foo bar"'` for explicit JSON strings
223-
- `--param 'ids=["a","b"]'` or `--param 'opts={"k":1}'` for arrays/objects
280+
- `--param 'ids=["a","b"]'` or `--param 'opts={"k":1}'` for arrays/objects

frontend/src/app/useHomeData.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ function useNeighborsDataFlow(args: {
306306
} = args;
307307

308308
const neighborsResults = useStore((state) => state.neighborsResults);
309+
const neighborsMetric = useStore((state) => state.neighborsMetric);
309310
const neighborsLoading = useStore((state) => state.neighborsLoading);
310311
const neighborsError = useStore((state) => state.neighborsError);
311312
const beginNeighborsQuery = useStore((state) => state.beginNeighborsQuery);
@@ -359,7 +360,7 @@ function useNeighborsDataFlow(args: {
359360

360361
if (abort.signal.aborted) return;
361362

362-
setNeighborsResults(response.results);
363+
setNeighborsResults(response.results, response.metric);
363364
} catch (err) {
364365
if (isAbortError(err)) return;
365366
console.error("Failed to fetch neighbors:", err);
@@ -402,6 +403,7 @@ function useNeighborsDataFlow(args: {
402403
return {
403404
neighborsError,
404405
neighborsLoading,
406+
neighborsMetric,
405407
neighborsResults,
406408
hasMoreNeighbors,
407409
loadMoreNeighbors,
@@ -513,6 +515,8 @@ export function useHomeData(): {
513515
selectionSamples: selectedSamples,
514516
neighborSamples:
515517
samplesFlow.selectedIdsList.length === 1 ? derivedNeighborSamples : [],
518+
neighborsMetric:
519+
samplesFlow.selectedIdsList.length === 1 ? neighborsFlow.neighborsMetric : null,
516520
neighborsLoading:
517521
samplesFlow.selectedIdsList.length === 1 ? neighborsFlow.neighborsLoading : false,
518522
hasMoreNeighbors:
@@ -532,6 +536,7 @@ export function useHomeData(): {
532536
neighborsFlow.loadMoreNeighbors,
533537
neighborsFlow.neighborsError,
534538
neighborsFlow.neighborsLoading,
539+
neighborsFlow.neighborsMetric,
535540
samplesCollection,
536541
samplesFlow.isLassoSelection,
537542
samplesFlow.selectedAnchorId,
@@ -545,4 +550,4 @@ export function useHomeData(): {
545550
error: samplesFlow.error,
546551
isLoading: samplesFlow.isLoading,
547552
};
548-
}
553+
}

frontend/src/components/SampleDerivedSpace.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { AlertCircle, Loader2 } from "lucide-react";
44

5+
import { getDistanceMetricLabel } from "@/lib/similarity";
56
import { cn } from "@/lib/utils";
67
import { useStore } from "@/store/useStore";
78
import type { Sample } from "@/types";
@@ -12,6 +13,7 @@ import { SampleTile } from "./SampleTile";
1213
interface SampleDerivedSpaceProps {
1314
selectionSamples: Sample[];
1415
neighborSamples: Sample[];
16+
neighborsMetric: string | null;
1517
neighborsLoading: boolean;
1618
hasMoreNeighbors: boolean;
1719
loadMoreNeighbors?: () => void;
@@ -35,6 +37,7 @@ function getAnchorTileWidth(sample: Sample): number {
3537
export function SampleDerivedSpace({
3638
selectionSamples,
3739
neighborSamples,
40+
neighborsMetric,
3841
neighborsLoading,
3942
hasMoreNeighbors,
4043
loadMoreNeighbors,
@@ -45,6 +48,7 @@ export function SampleDerivedSpace({
4548

4649
const showNeighbors =
4750
neighborSamples.length > 0 || neighborsLoading || neighborsError !== null;
51+
const distanceMetricLabel = getDistanceMetricLabel(neighborsMetric);
4852

4953
return (
5054
<div className="flex flex-1 min-h-0 flex-col">
@@ -75,7 +79,7 @@ export function SampleDerivedSpace({
7579
<div className="flex min-h-0 flex-1 flex-col">
7680
<div className="h-6 min-h-[24px] border-y border-border bg-secondary/20 px-2 flex items-center">
7781
<span className="text-[11px] leading-4 text-muted-foreground">
78-
Nearest neighbors
82+
Nearest neighbors{distanceMetricLabel ? ` · ${distanceMetricLabel}` : ""}
7983
</span>
8084
</div>
8185
<div className="flex min-h-0 flex-1 w-full overflow-hidden">
@@ -87,6 +91,7 @@ export function SampleDerivedSpace({
8791
scrollResetKey={neighborsScrollResetKey}
8892
className="w-full"
8993
showRankSimilarityBadge
94+
distanceMetric={neighborsMetric}
9095
/>
9196
) : neighborsLoading ? (
9297
<div className="flex flex-1 items-center justify-center text-muted-foreground">
@@ -103,4 +108,4 @@ export function SampleDerivedSpace({
103108
)}
104109
</div>
105110
);
106-
}
111+
}

frontend/src/components/SampleGridView.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import { useVirtualizer } from "@tanstack/react-virtual";
55
import justifiedLayout from "justified-layout";
66

7+
import { formatDistanceValue, getDistanceMetricLabel } from "@/lib/similarity";
78
import { cn } from "@/lib/utils";
89
import { useStore } from "@/store/useStore";
910
import type { Sample } from "@/types";
@@ -17,6 +18,7 @@ interface SampleGridViewProps {
1718
scrollResetKey?: string;
1819
className?: string;
1920
showRankSimilarityBadge?: boolean;
21+
distanceMetric?: string | null;
2022
}
2123

2224
const BOX_SPACING = 2;
@@ -112,6 +114,7 @@ export function SampleGridView({
112114
scrollResetKey,
113115
className,
114116
showRankSimilarityBadge = false,
117+
distanceMetric = null,
115118
}: SampleGridViewProps) {
116119
const containerRef = useRef<HTMLDivElement>(null);
117120
const [containerWidth, setContainerWidth] = useState(0);
@@ -266,19 +269,18 @@ export function SampleGridView({
266269
const isHovered = hoveredId === sample.id;
267270
const sampleIndex = row.startIndex + index;
268271
const distance = (sample as { distance?: number }).distance;
269-
const similarity =
270-
typeof distance === "number"
271-
? Math.max(0, Math.min(1, 1 - distance))
272-
: null;
273-
const similarityPercent =
274-
similarity !== null ? Math.round(similarity * 100) : null;
272+
const distanceLabel =
273+
typeof distance === "number" ? formatDistanceValue(distance) : null;
274+
const preciseDistanceLabel =
275+
typeof distance === "number" ? formatDistanceValue(distance, 6) : null;
276+
const distanceMetricLabel = getDistanceMetricLabel(distanceMetric) ?? "distance";
275277
const metricBadge =
276-
showRankSimilarityBadge && similarityPercent !== null
277-
? `#${sampleIndex + 1} · ${similarityPercent}%`
278+
showRankSimilarityBadge && distanceLabel !== null
279+
? `#${sampleIndex + 1} · d ${distanceLabel}`
278280
: null;
279281
const metricBadgeTitle =
280-
showRankSimilarityBadge && similarity !== null
281-
? `Rank ${sampleIndex + 1}, cosine similarity ${(similarity * 100).toFixed(1)}%`
282+
showRankSimilarityBadge && preciseDistanceLabel !== null
283+
? `Rank ${sampleIndex + 1}, ${distanceMetricLabel} ${preciseDistanceLabel}. Lower is closer.`
282284
: undefined;
283285

284286
return (
@@ -313,4 +315,4 @@ export function SampleGridView({
313315
</div>
314316
</div>
315317
);
316-
}
318+
}

frontend/src/components/ScatterPanel.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
fetchDataset,
2929
fetchEmbeddings,
3030
isLayoutNotFoundError,
31+
setLayoutView,
3132
} from "@/lib/api";
3233

3334
interface ScatterPanelProps {
@@ -61,6 +62,9 @@ export function ScatterPanel({
6162
requestedLayoutKey,
6263
scatterLabelOverlayMode,
6364
setScatterLabelOverlayMode,
65+
activeWorkspaceId,
66+
layoutViews,
67+
setLayoutViewCamera,
6468
} = useStore();
6569

6670
const highlightedIds = useMemo(() => {
@@ -321,6 +325,24 @@ export function ScatterPanel({
321325
);
322326

323327
const embeddings = resolvedLayoutKey ? embeddingsByLayoutKey[resolvedLayoutKey] ?? null : null;
328+
const savedView3d = resolvedLayoutKey
329+
? (layoutViews[resolvedLayoutKey]?.camera_3d ?? null)
330+
: null;
331+
332+
const handleView3DChange = useCallback(
333+
(view: NonNullable<typeof savedView3d>) => {
334+
if (!activeWorkspaceId || !resolvedLayoutKey) return;
335+
setLayoutViewCamera(resolvedLayoutKey, view);
336+
void setLayoutView({
337+
workspaceId: activeWorkspaceId,
338+
layoutKey: resolvedLayoutKey,
339+
camera3d: view,
340+
}).catch((err) => {
341+
console.error("Failed to persist 3D scatter view:", err);
342+
});
343+
},
344+
[activeWorkspaceId, resolvedLayoutKey, setLayoutViewCamera]
345+
);
324346

325347
useEffect(() => {
326348
if (!resolvedLayoutKey) return;
@@ -392,12 +414,14 @@ export function ScatterPanel({
392414
labelsInfo,
393415
labelFilter,
394416
semanticLabelDisplayMode: scatterLabelOverlayMode,
417+
initialView3d: savedView3d,
395418
selectedIds,
396419
highlightedIds,
397420
hoveredId,
398421
setSelectedIds,
399422
beginLassoSelection,
400423
setHoveredId,
424+
onView3DChange: handleView3DChange,
401425
});
402426

403427
const focusLayout = useCallback(() => {

0 commit comments

Comments
 (0)