hyperview 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {hyperview-0.4.2 → hyperview-0.5.0}/.agents/skills/hyperview-cli/SKILL.md +10 -4
  2. {hyperview-0.4.2 → hyperview-0.5.0}/.agents/skills/hyperview-cli/references/commands.md +58 -1
  3. {hyperview-0.4.2 → hyperview-0.5.0}/PKG-INFO +2 -2
  4. {hyperview-0.4.2 → hyperview-0.5.0}/README.md +1 -1
  5. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/_version.py +2 -2
  6. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/cli.py +109 -0
  7. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/core/selection.py +20 -0
  8. hyperview-0.5.0/src/hyperview/figures/__init__.py +13 -0
  9. hyperview-0.5.0/src/hyperview/figures/colors.py +102 -0
  10. hyperview-0.5.0/src/hyperview/figures/render.py +628 -0
  11. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/runtime.py +72 -0
  12. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/app.py +26 -3
  13. {hyperview-0.4.2/src/hyperview/server/static/_not-found → hyperview-0.5.0/src/hyperview/server/static/404}/index.html +1 -1
  14. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/404.html +1 -1
  15. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/__next.__PAGE__.txt +2 -2
  16. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/__next._full.txt +2 -2
  17. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/__next._head.txt +1 -1
  18. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/__next._index.txt +1 -1
  19. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/__next._tree.txt +1 -1
  20. hyperview-0.5.0/src/hyperview/server/static/_next/static/chunks/d91860e761ca6b99.js +8 -0
  21. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/__next._full.txt +1 -1
  22. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/__next._head.txt +1 -1
  23. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/__next._index.txt +1 -1
  24. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
  25. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/__next._not-found.txt +1 -1
  26. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/__next._tree.txt +1 -1
  27. {hyperview-0.4.2/src/hyperview/server/static/404 → hyperview-0.5.0/src/hyperview/server/static/_not-found}/index.html +1 -1
  28. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_not-found/index.txt +1 -1
  29. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/index.html +1 -1
  30. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/index.txt +2 -2
  31. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/storage/lancedb_backend.py +69 -2
  32. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/storage/memory_backend.py +25 -10
  33. hyperview-0.5.0/src/hyperview/storage/metrics.py +99 -0
  34. hyperview-0.4.2/src/hyperview/server/static/_next/static/chunks/52ce757ecb4458ef.js +0 -8
  35. {hyperview-0.4.2 → hyperview-0.5.0}/.agents/skills/hyperview-cli/references/native-panels.md +0 -0
  36. {hyperview-0.4.2 → hyperview-0.5.0}/.agents/skills/hyperview-cli/references/plugins.md +0 -0
  37. {hyperview-0.4.2 → hyperview-0.5.0}/.gitignore +0 -0
  38. {hyperview-0.4.2 → hyperview-0.5.0}/LICENSE +0 -0
  39. {hyperview-0.4.2 → hyperview-0.5.0}/pyproject.toml +0 -0
  40. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/__init__.py +0 -0
  41. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/api.py +0 -0
  42. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/core/__init__.py +0 -0
  43. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/core/dataset.py +0 -0
  44. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/core/sample.py +0 -0
  45. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/__init__.py +0 -0
  46. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/compute.py +0 -0
  47. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/engine.py +0 -0
  48. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/pipelines.py +0 -0
  49. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/projection.py +0 -0
  50. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/providers/__init__.py +0 -0
  51. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/embeddings/providers/lancedb_providers.py +0 -0
  52. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/extensions.py +0 -0
  53. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/__init__.py +0 -0
  54. {hyperview-0.4.2/src/hyperview/server/static/_next/static/z3NN_l9swds_iWzQNtTap → hyperview-0.5.0/src/hyperview/server/static/_next/static/1tZtVoPDFS4Vb1x9TZkO-}/_buildManifest.js +0 -0
  55. {hyperview-0.4.2/src/hyperview/server/static/_next/static/z3NN_l9swds_iWzQNtTap → hyperview-0.5.0/src/hyperview/server/static/_next/static/1tZtVoPDFS4Vb1x9TZkO-}/_clientMiddlewareManifest.json +0 -0
  56. {hyperview-0.4.2/src/hyperview/server/static/_next/static/z3NN_l9swds_iWzQNtTap → hyperview-0.5.0/src/hyperview/server/static/_next/static/1tZtVoPDFS4Vb1x9TZkO-}/_ssgManifest.js +0 -0
  57. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/4899f901f4ca16ad.css +0 -0
  58. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/567993cf36cd4ab1.js +0 -0
  59. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/661a08547c83f565.js +0 -0
  60. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/86c1fc4cf542f408.js +0 -0
  61. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/a6dad97d9634a72d.js +0 -0
  62. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/a6dad97d9634a72d.js.map +0 -0
  63. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/e954ba82c0a04100.js +0 -0
  64. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/eac713f252f03efd.js +0 -0
  65. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/f29dd35a99c216ea.js +0 -0
  66. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/chunks/turbopack-cb59e03a04a579d1.js +0 -0
  67. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/1bffadaabf893a1e-s.7cd81963.woff2 +0 -0
  68. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/2bbe8d2671613f1f-s.76dcb0b2.woff2 +0 -0
  69. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/2c55a0e60120577a-s.2a48534a.woff2 +0 -0
  70. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/5476f68d60460930-s.c995e352.woff2 +0 -0
  71. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/83afe278b6a6bb3c-s.p.3a6ba036.woff2 +0 -0
  72. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/9c72aa0f40e4eef8-s.18a48cbc.woff2 +0 -0
  73. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/server/static/_next/static/media/ad66f9afd8947f86-s.7a40eb73.woff2 +0 -0
  74. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/skill_install.py +0 -0
  75. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/storage/__init__.py +0 -0
  76. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/storage/backend.py +0 -0
  77. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/storage/config.py +0 -0
  78. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/storage/schema.py +0 -0
  79. {hyperview-0.4.2 → hyperview-0.5.0}/src/hyperview/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  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.
4
4
  license: MIT
5
5
  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.
6
6
  metadata:
@@ -29,6 +29,7 @@ HyperView currently supports Python 3.10 through 3.13; `--python 3.12` keeps the
29
29
  - Start or control a running HyperView runtime.
30
30
  - Register a custom embedding provider.
31
31
  - Compute embeddings or layouts without restarting the UI.
32
+ - Export paper-ready static 3D embedding figures without a browser or Node runtime.
32
33
  - Switch the active workspace, layout, or selection in a running session.
33
34
  - Add or remove agent-authored native module panels from local files.
34
35
  - 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
41
42
  4. Register a provider if needed.
42
43
  5. Submit embedding or layout jobs through the runtime.
43
44
  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.
45
47
 
46
48
  ## Current model
47
49
 
@@ -54,8 +56,10 @@ HyperView currently supports Python 3.10 through 3.13; `--python 3.12` keeps the
54
56
  - Plugins are repo-local extension folders with `extension.toml`, optional Python tools, and optional native panel modules.
55
57
  - Plugin panels call backend tools through `HyperViewPanelSDK.hooks.useTool()` or `hyperview tools run`.
56
58
  - 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.
57
61
 
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.
59
63
  Read [references/native-panels.md](references/native-panels.md) when the task involves authoring or registering a custom panel.
60
64
  Read [references/plugins.md](references/plugins.md) when the task involves backend-plus-frontend plugins/extensions.
61
65
 
@@ -75,6 +79,8 @@ Read [references/plugins.md](references/plugins.md) when the task involves backe
75
79
  - For provider args, use repeated `--provider-arg key=value` flags.
76
80
  - Treat the workspace as the durable unit. Changing datasets means setting a new workspace dataset, not switching among many datasets inside one workspace.
77
81
  - 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.
78
84
  - The first `uv run hyperview ...` invocation in a session can take 30+ seconds (torch/datasets imports). Allow generous timeouts and avoid sending SIGINT.
79
85
 
80
86
  ## Inspecting runtime state
@@ -85,4 +91,4 @@ The runtime exposes JSON discovery endpoints alongside the CLI. Use them to obta
85
91
  - `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 ...`.
86
92
  - `GET /api/tools` &mdash; registered tool URIs (also returned by `hyperview tools list --json`).
87
93
 
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.
@@ -131,6 +131,63 @@ hyperview jobs list --json
131
131
  hyperview jobs inspect <job-id> --json
132
132
  ```
133
133
 
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
+
134
191
  ## Runtime UI
135
192
 
136
193
  Discover an existing layout key and sample IDs before mutating runtime state:
@@ -220,4 +277,4 @@ hyperview tools run selection_profile.summarize \
220
277
  - `--param 'top_k=5'` for numbers
221
278
  - `--param 'enabled=true'` for booleans
222
279
  - `--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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperview
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Open-source dataset curation with hyperbolic embeddings visualization
5
5
  Project-URL: Homepage, https://github.com/Hyper3Labs/HyperView
6
6
  Project-URL: Documentation, https://github.com/Hyper3Labs/HyperView#readme
@@ -206,7 +206,7 @@ Traditional Euclidean embeddings struggle with hierarchical data. In Euclidean s
206
206
 
207
207
  ## Community
208
208
 
209
- **Weekly Open Discussion** — Every Tuesday at 15:00 UTC on [Discord](https://discord.gg/Az7k4Ure?event=1469730571440885944)
209
+ **Weekly Open Discussion** — Every Tuesday at 15:00 UTC on [Discord](https://discord.gg/Za3rBkTPSf)
210
210
 
211
211
  Join us to see the latest features demoed live, walk through new code, and get help with local setup. Whether you're a core maintainer or looking for your first contribution, everyone is welcome.
212
212
 
@@ -155,7 +155,7 @@ Traditional Euclidean embeddings struggle with hierarchical data. In Euclidean s
155
155
 
156
156
  ## Community
157
157
 
158
- **Weekly Open Discussion** — Every Tuesday at 15:00 UTC on [Discord](https://discord.gg/Az7k4Ure?event=1469730571440885944)
158
+ **Weekly Open Discussion** — Every Tuesday at 15:00 UTC on [Discord](https://discord.gg/Za3rBkTPSf)
159
159
 
160
160
  Join us to see the latest features demoed live, walk through new code, and get help with local setup. Whether you're a core maintainer or looking for your first contribution, everyone is welcome.
161
161
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.4.2'
22
- __version_tuple__ = version_tuple = (0, 4, 2)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -13,7 +13,10 @@ from urllib.request import Request, urlopen
13
13
 
14
14
  from hyperview import Dataset
15
15
  from hyperview.api import Session
16
+ from hyperview.core.selection import OrbitViewState3D
17
+ from hyperview.figures import FigureRenderOptions, render_layout_figure
16
18
  from hyperview.runtime import HyperViewRuntime, ProviderRegistry, WorkspaceRegistry
19
+ from hyperview.storage.schema import parse_layout_dimension
17
20
 
18
21
 
19
22
  def _read_json_response(response: Any) -> Any:
@@ -270,6 +273,28 @@ def _build_control_parser() -> argparse.ArgumentParser:
270
273
  jobs_inspect.add_argument("job_id")
271
274
  _add_json_flag(jobs_inspect)
272
275
 
276
+ figure_parser = subparsers.add_parser("figure")
277
+ figure_subparsers = figure_parser.add_subparsers(dest="figure_command", required=True)
278
+ figure_export = figure_subparsers.add_parser("export")
279
+ figure_export.add_argument("output")
280
+ figure_export.add_argument("--workspace")
281
+ figure_export.add_argument("--dataset")
282
+ figure_export.add_argument("--layout")
283
+ figure_export.add_argument("--width", type=int, default=900)
284
+ figure_export.add_argument("--height", type=int, default=900)
285
+ figure_export.add_argument("--scale", type=int, default=2)
286
+ figure_export.add_argument("--theme", choices=["dark", "light"], default="light")
287
+ figure_export.add_argument("--background")
288
+ figure_export.add_argument("--point-radius", type=float, default=4.0)
289
+ figure_export.add_argument("--guide-style", choices=["paper", "rings", "outline", "none"], default="paper")
290
+ figure_export.add_argument("--guide-alpha", type=int)
291
+ figure_export.add_argument("--legend", choices=["auto", "on", "off", "direct"], default="auto")
292
+ figure_export.add_argument("--title")
293
+ figure_export.add_argument("--show-selection", action="store_true")
294
+ figure_export.add_argument("--no-guide", action="store_true")
295
+ figure_export.add_argument("--ignore-selection", action="store_true")
296
+ _add_json_flag(figure_export)
297
+
273
298
  ui_parser = subparsers.add_parser("ui")
274
299
  ui_subparsers = ui_parser.add_subparsers(dest="ui_command", required=True)
275
300
  ui_workspace = ui_subparsers.add_parser("workspace")
@@ -598,6 +623,87 @@ def _run_jobs_command(args: argparse.Namespace) -> None:
598
623
  raise RuntimeError(f"Unsupported jobs command: {args.jobs_command}")
599
624
 
600
625
 
626
+ def _resolve_figure_layout_key(
627
+ dataset: Dataset,
628
+ active_layout_key: str | None,
629
+ requested_layout_key: str | None,
630
+ ) -> str:
631
+ layouts = dataset.list_layouts()
632
+
633
+ if requested_layout_key and requested_layout_key != "active":
634
+ return requested_layout_key
635
+
636
+ if requested_layout_key == "active":
637
+ if not active_layout_key:
638
+ raise RuntimeError("No active layout is set for this workspace")
639
+ return active_layout_key
640
+
641
+ if active_layout_key:
642
+ try:
643
+ if parse_layout_dimension(active_layout_key) == 3:
644
+ return active_layout_key
645
+ except ValueError:
646
+ pass
647
+
648
+ for layout in layouts:
649
+ try:
650
+ if parse_layout_dimension(layout.layout_key) == 3:
651
+ return layout.layout_key
652
+ except ValueError:
653
+ continue
654
+
655
+ raise RuntimeError("No 3D layout is available for figure export")
656
+
657
+
658
+ def _run_figure_command(args: argparse.Namespace) -> None:
659
+ if args.figure_command != "export":
660
+ raise RuntimeError(f"Unsupported figure command: {args.figure_command}")
661
+
662
+ runtime = HyperViewRuntime()
663
+ workspace = runtime.get_workspace(args.workspace)
664
+ dataset = runtime.get_dataset(workspace.id, args.dataset)
665
+ layout_key = _resolve_figure_layout_key(
666
+ dataset,
667
+ workspace.ui.active_layout_key,
668
+ args.layout,
669
+ )
670
+
671
+ layout_view = workspace.ui.layout_views.get(layout_key)
672
+ camera = layout_view.camera_3d if layout_view is not None else None
673
+ view = OrbitViewState3D(**camera) if camera is not None else None
674
+
675
+ options = FigureRenderOptions(
676
+ width=args.width,
677
+ height=args.height,
678
+ scale=args.scale,
679
+ theme=args.theme,
680
+ background=args.background,
681
+ point_radius=args.point_radius,
682
+ show_guide=not args.no_guide,
683
+ guide_style=args.guide_style,
684
+ guide_alpha=args.guide_alpha,
685
+ legend=args.legend,
686
+ title=args.title,
687
+ selected_ids=set(workspace.ui.selected_ids) if args.show_selection and not args.ignore_selection else set(),
688
+ )
689
+ try:
690
+ result = render_layout_figure(
691
+ dataset=dataset,
692
+ layout_key=layout_key,
693
+ output_path=args.output,
694
+ view=view,
695
+ options=options,
696
+ )
697
+ except ValueError as exc:
698
+ raise SystemExit(str(exc)) from None
699
+
700
+ payload = {"figure": result.to_dict()}
701
+ if args.json:
702
+ _print_output(payload, as_json=True)
703
+ return
704
+ print(f"Wrote {result.output_path} ({result.width}x{result.height}, {result.num_points} points)")
705
+
706
+
601
707
  def _run_ui_command(args: argparse.Namespace) -> None:
602
708
  base_url = _server_base_url(args.host, args.port)
603
709
  if args.ui_command == "workspace" and args.ui_workspace_command == "set":
@@ -780,6 +886,9 @@ def main(argv: list[str] | None = None):
780
886
  if args.command == "jobs":
781
887
  _run_jobs_command(args)
782
888
  return
889
+ if args.command == "figure":
890
+ _run_figure_command(args)
891
+ return
783
892
  if args.command == "ui":
784
893
  _run_ui_command(args)
785
894
  return
@@ -236,6 +236,26 @@ def _project_points_3d_to_screen(
236
236
  return screen_x, screen_y, depth, pixel_index
237
237
 
238
238
 
239
+ def build_mvp_for_orbit(
240
+ view: OrbitViewState3D,
241
+ coords: np.ndarray,
242
+ viewport_width: int,
243
+ viewport_height: int,
244
+ ) -> np.ndarray:
245
+ """Build an orbit-camera MVP matrix matching the 3D scatter renderer."""
246
+ return _build_mvp_for_orbit(view, coords, viewport_width, viewport_height)
247
+
248
+
249
+ def project_points_3d_to_screen(
250
+ mvp: np.ndarray,
251
+ coords: np.ndarray,
252
+ width: int,
253
+ height: int,
254
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
255
+ """Project 3D points to screen coordinates using scatter renderer math."""
256
+ return _project_points_3d_to_screen(mvp, coords, width, height)
257
+
258
+
239
259
  def select_ids_for_3d_lasso(
240
260
  *,
241
261
  ids: list[str],
@@ -0,0 +1,13 @@
1
+ """Static figure export helpers for HyperView."""
2
+
3
+ from hyperview.figures.render import (
4
+ FigureExportResult,
5
+ FigureRenderOptions,
6
+ render_layout_figure,
7
+ )
8
+
9
+ __all__ = [
10
+ "FigureExportResult",
11
+ "FigureRenderOptions",
12
+ "render_layout_figure",
13
+ ]
@@ -0,0 +1,102 @@
1
+ """Deterministic categorical colors shared by static figure export."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+
7
+ MISSING_LABEL_SENTINEL = "undefined"
8
+ MISSING_LABEL_COLOR = "#39d3cc"
9
+ FALLBACK_LABEL_COLOR = "#8b949e"
10
+
11
+ TAB_20_LABEL_PALETTE = [
12
+ "#0072b2",
13
+ "#e69f00",
14
+ "#009e73",
15
+ "#d55e00",
16
+ "#cc79a7",
17
+ "#56b4e9",
18
+ "#000000",
19
+ "#7f7f7f",
20
+ "#332288",
21
+ "#88ccee",
22
+ "#44aa99",
23
+ "#117733",
24
+ "#999933",
25
+ "#ddcc77",
26
+ "#cc6677",
27
+ "#882255",
28
+ "#aa4499",
29
+ "#6699cc",
30
+ "#661100",
31
+ "#888888",
32
+ ]
33
+
34
+ OVERFLOW_HUE_STEP_DEGREES = 137.508
35
+
36
+
37
+ def normalize_label(label: str | None) -> str:
38
+ return label if label else MISSING_LABEL_SENTINEL
39
+
40
+
41
+ def _stable_label_sort_key(label: str) -> tuple[int, str]:
42
+ return (1, label) if label == MISSING_LABEL_SENTINEL else (0, label)
43
+
44
+
45
+ def _overflow_color(index: int) -> str:
46
+ hue = (43.0 + index * OVERFLOW_HUE_STEP_DEGREES) % 360.0
47
+ saturation = [0.72, 0.64, 0.78][index % 3]
48
+ lightness = [0.46, 0.54, 0.38, 0.62][(index // 3) % 4]
49
+ r, g, b = colorsys.hls_to_rgb(hue / 360.0, lightness, saturation)
50
+ return f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
51
+
52
+
53
+ def create_label_color_map(labels: list[str | None]) -> dict[str, str]:
54
+ unique = sorted({normalize_label(label) for label in labels}, key=_stable_label_sort_key)
55
+ colors: dict[str, str] = {}
56
+ used: set[str] = set()
57
+ non_missing_index = 0
58
+ overflow_index = 0
59
+
60
+ for label in unique:
61
+ if label == MISSING_LABEL_SENTINEL:
62
+ colors[label] = MISSING_LABEL_COLOR
63
+ used.add(MISSING_LABEL_COLOR.lower())
64
+ continue
65
+
66
+ if non_missing_index < len(TAB_20_LABEL_PALETTE):
67
+ candidate = TAB_20_LABEL_PALETTE[non_missing_index]
68
+ else:
69
+ candidate = _overflow_color(overflow_index)
70
+
71
+ safety = 0
72
+ while candidate.lower() in used and safety < 2048:
73
+ overflow_index += 1
74
+ candidate = _overflow_color(overflow_index)
75
+ safety += 1
76
+
77
+ if non_missing_index >= len(TAB_20_LABEL_PALETTE):
78
+ overflow_index += 1
79
+
80
+ if candidate.lower() in used:
81
+ candidate = FALLBACK_LABEL_COLOR
82
+
83
+ colors[label] = candidate
84
+ used.add(candidate.lower())
85
+ non_missing_index += 1
86
+
87
+ return colors
88
+
89
+
90
+ def hex_to_rgba(color: str, alpha: int = 255) -> tuple[int, int, int, int]:
91
+ raw = color.strip()
92
+ if raw.startswith("#"):
93
+ raw = raw[1:]
94
+ if len(raw) == 3:
95
+ raw = "".join(ch * 2 for ch in raw)
96
+ if len(raw) not in {6, 8}:
97
+ return 255, 255, 255, alpha
98
+ r = int(raw[0:2], 16)
99
+ g = int(raw[2:4], 16)
100
+ b = int(raw[4:6], 16)
101
+ a = int(raw[6:8], 16) if len(raw) == 8 else alpha
102
+ return r, g, b, a