From 7eecbb2266c76fab9087431e3aa9f29a54ab5693 Mon Sep 17 00:00:00 2001
From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com>
Date: Wed, 26 Aug 2026 05:02:51 -0700
Subject: [PATCH] refactor(ui): extract the dialog's static annotation overlay
into a pattern
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The picture-with-labels mechanism of the dataset asset dialog — an aspect-ratio
box, the image filling it, an SVG carrying a viewBox of the asset's pixel size,
class colour from the palette — becomes StaticAnnotationOverlay under
patterns/, taking width, height, src and wire annotations and holding no store
and no input. The dialog consumes it unchanged in behaviour; the pre-processing
preview cells consume it next.
---
frontend/ui-core/src/index.ts | 1 +
.../src/patterns/StaticAnnotationOverlay.tsx | 119 ++++++++++++++++++
.../patterns/staticAnnotationOverlay.test.tsx | 98 +++++++++++++++
.../src/screens/DatasetAssetDialog.tsx | 111 +++-------------
4 files changed, 238 insertions(+), 91 deletions(-)
create mode 100644 frontend/ui-core/src/patterns/StaticAnnotationOverlay.tsx
create mode 100644 frontend/ui-core/src/patterns/staticAnnotationOverlay.test.tsx
diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts
index ed31f4ee..1b950a72 100644
--- a/frontend/ui-core/src/index.ts
+++ b/frontend/ui-core/src/index.ts
@@ -450,3 +450,4 @@ export {
type ExportTargetSelectProps,
} from "./patterns/ExportTargetSelect.js";
export { describeTargetDrops } from "./data/refusals.js";
+export { StaticAnnotationOverlay, type StaticAnnotationOverlayProps } from "./patterns/StaticAnnotationOverlay.js";
diff --git a/frontend/ui-core/src/patterns/StaticAnnotationOverlay.tsx b/frontend/ui-core/src/patterns/StaticAnnotationOverlay.tsx
new file mode 100644
index 00000000..bdd5dce1
--- /dev/null
+++ b/frontend/ui-core/src/patterns/StaticAnnotationOverlay.tsx
@@ -0,0 +1,119 @@
+/**
+ * A picture with its labels drawn over it, and nothing behind them.
+ *
+ * The shapes are the annotator's own renderers (`BboxShape` and its siblings),
+ * so a box here is the box the annotator would draw — but there is no store, no
+ * selection, no tool and no pointer handling. It is a viewer for places that
+ * only look: the dataset's member dialog, a pre-processing preview cell.
+ *
+ * The overlay is exact by construction rather than by measurement. The picture
+ * box is given the asset's own aspect ratio, the ` ` fills it and the ``
+ * carries a `viewBox` of the asset's pixel size, so user units map onto the
+ * rendered picture uniformly at every width without a `ResizeObserver`. A
+ * caller without dimensions has nothing to hand this component — coordinates
+ * without a frame cannot be placed — and renders its own picture instead.
+ */
+
+import type { CSSProperties, JSX } from "react";
+import {
+ BboxShape,
+ PolygonShape,
+ PolylineShape,
+ STROKE_PX,
+ parseGeometry,
+ type Geometry,
+} from "@visionset/annotator";
+
+import type { WireAnnotation } from "../annotator/jobQueries";
+import { classColor, type LabelClass } from "../palette";
+
+export interface StaticAnnotationOverlayProps {
+ /** The picture's pixel size, which is the frame every coordinate is read in. */
+ readonly width: number;
+ readonly height: number;
+ readonly src: string;
+ readonly alt: string;
+ /** Wire annotations as the dataset and job routes answer them; a classification tag draws nothing. */
+ readonly annotations: readonly WireAnnotation[];
+ /** The schema's classes, for their declared colours; a class not declared here takes the engine's derived hue. */
+ readonly classes?: readonly LabelClass[];
+}
+
+export function StaticAnnotationOverlay({
+ width,
+ height,
+ src,
+ alt,
+ annotations,
+ classes = [],
+}: StaticAnnotationOverlayProps): JSX.Element {
+ const declared = new Map(classes.map((one) => [one.name, one]));
+ const colorOf = (labelClass: string): string => classColor(declared.get(labelClass), labelClass);
+
+ // The stage scales its stroke variables with the zoom; a static viewer has no
+ // zoom, so the stroke is a fraction of the picture's width instead, and stays
+ // the same apparent weight however large the picture is drawn.
+ const stroke = Math.max(1, (STROKE_PX * width) / 800);
+ const strokeVars = {
+ "--vs-stroke": `${stroke}px`,
+ "--vs-stroke-selected": `${stroke}px`,
+ } as CSSProperties;
+
+ return (
+
+
+
+ {annotations.map((annotation) => (
+
+ ))}
+
+
+ );
+}
+
+function Shape({
+ annotation,
+ color,
+}: {
+ readonly annotation: WireAnnotation;
+ readonly color: string;
+}): JSX.Element | null {
+ const geometry = geometryOf(annotation);
+ if (geometry === null || geometry.type === "classification_tag") return null;
+ return (
+
+ {geometry.type === "bbox" ? (
+
+ ) : geometry.type === "polygon" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** `null` for a geometry the engine does not draw — a stored shape is never a reason to fail the page. */
+function geometryOf(annotation: WireAnnotation): Geometry | null {
+ try {
+ return parseGeometry(annotation.geometry);
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend/ui-core/src/patterns/staticAnnotationOverlay.test.tsx b/frontend/ui-core/src/patterns/staticAnnotationOverlay.test.tsx
new file mode 100644
index 00000000..9cd0163e
--- /dev/null
+++ b/frontend/ui-core/src/patterns/staticAnnotationOverlay.test.tsx
@@ -0,0 +1,98 @@
+/**
+ * The static overlay, held to the three things a consumer relies on: one drawn
+ * shape per placeable annotation, a `viewBox` that is the picture's pixel frame,
+ * and the class palette the annotator itself draws with. Whether the SVG's box
+ * lands on the image's box is a browser fact and lives in `e2e/dataset.spec.ts`.
+ */
+
+import { classColor } from "@visionset/annotator";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import type { WireAnnotation } from "../annotator/jobQueries";
+import type { LabelClass } from "../palette";
+import { StaticAnnotationOverlay } from "./StaticAnnotationOverlay";
+
+const VEHICLE: LabelClass = { name: "vehicle", geometries: ["bbox"], color: "#38bdf8", attributes: [] };
+
+function annotation(id: string, labelClass: string, geometry: unknown): WireAnnotation {
+ return {
+ id,
+ asset_id: "asset-1",
+ label_class: labelClass,
+ schema_version: 1,
+ geometry,
+ attributes: {},
+ provenance: "human",
+ model_ref: null,
+ confidence: null,
+ job_id: null,
+ };
+}
+
+const BOX = annotation("box", "vehicle", { type: "bbox", x: 10, y: 20, width: 100, height: 50 });
+const LANE = annotation("lane", "lane", {
+ type: "polygon",
+ points: [
+ [0, 0],
+ [50, 0],
+ [25, 40],
+ ],
+});
+const TAG = annotation("tag", "night", { type: "classification_tag" });
+const BROKEN = annotation("broken", "vehicle", { type: "hexagon" });
+
+describe("StaticAnnotationOverlay", () => {
+ it("draws one shape per placeable annotation in the picture's own pixel frame", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("preview-overlay").getAttribute("viewBox")).toBe("0 0 640 480");
+ expect(screen.getByTestId("preview-shape-box").getAttribute("data-geometry")).toBe("bbox");
+ expect(screen.getByTestId("preview-shape-lane").getAttribute("data-geometry")).toBe("polygon");
+ // A tag has no place on the picture, and a geometry the engine cannot parse
+ // is skipped rather than allowed to fail the page.
+ expect(screen.queryByTestId("preview-shape-tag")).toBeNull();
+ expect(screen.queryByTestId("preview-shape-broken")).toBeNull();
+ expect(screen.getByTestId("preview-overlay").querySelectorAll("[data-testid^='preview-shape-']")).toHaveLength(2);
+
+ const picture = screen.getByTestId("preview-picture");
+ expect(picture.style.aspectRatio).toBe("640 / 480");
+ expect(screen.getByTestId("preview-image").getAttribute("src")).toBe("blob:picture");
+ expect(screen.getByTestId("preview-image").getAttribute("alt")).toBe("frame 1");
+ });
+
+ it("colours a shape by the class palette: the declared colour, else the engine's derived hue", () => {
+ render(
+ ,
+ );
+
+ const box = screen.getByTestId("preview-shape-box").querySelector("rect");
+ expect(box?.getAttribute("stroke")).toBe("#38bdf8");
+ const lane = screen.getByTestId("preview-shape-lane").querySelector("polygon");
+ expect(lane?.getAttribute("stroke")).toBe(classColor(undefined, "lane"));
+ });
+
+ it("scales the stroke with the picture's width instead of a zoom", () => {
+ render(
+ ,
+ );
+ // The stage's shapes read `--vs-stroke`; without a zoom the value is a
+ // fraction of the width, so a 4K frame does not draw hairline boxes.
+ expect(screen.getByTestId("preview-overlay").style.getPropertyValue("--vs-stroke")).toBe("4px");
+ });
+});
diff --git a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
index 25b37ab4..c38a76c8 100644
--- a/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
+++ b/frontend/ui-core/src/screens/DatasetAssetDialog.tsx
@@ -14,26 +14,14 @@
* inventory — every label is drawn there — and a list of forty rows saying
* "box · model" beside it would repeat the overlay in words.
*
- * The overlay is exact by construction rather than by measurement. The picture
- * box is given the asset's own aspect ratio, the ` ` fills it and the ``
- * carries a `viewBox` of the asset's pixel size, so user units map onto the
- * rendered picture uniformly at every dialog width without a `ResizeObserver`.
- * An asset whose dimensions are not recorded gets the picture and no overlay,
- * because coordinates without a frame cannot be placed.
+ * The overlay is `StaticAnnotationOverlay`, exact by construction rather than by
+ * measurement. An asset whose dimensions are not recorded gets the picture and
+ * no overlay, because coordinates without a frame cannot be placed.
*/
import { ChevronLeft, ChevronRight, Eye, EyeOff, Trash2 } from "lucide-react";
-import { useState, type CSSProperties, type JSX, type KeyboardEvent } from "react";
-import {
- BboxShape,
- PolygonShape,
- PolylineShape,
- STROKE_PX,
- parseGeometry,
- parseLabelClass,
- type Geometry,
- type LabelClass,
-} from "@visionset/annotator";
+import { useState, type JSX, type KeyboardEvent } from "react";
+import { parseLabelClass, type LabelClass } from "@visionset/annotator";
import { AssetImage } from "../annotator/AssetImage";
import type { WireAnnotation } from "../annotator/jobQueries";
@@ -43,6 +31,7 @@ import { classColor } from "../palette";
import { Badge } from "../primitives/Badge";
import { Button } from "../primitives/Button";
import { DescriptionList, DescriptionRow } from "../patterns/DataDisplay";
+import { StaticAnnotationOverlay } from "../patterns/StaticAnnotationOverlay";
import {
Dialog,
DialogContent,
@@ -125,7 +114,7 @@ export function DatasetAssetDialog({
projectId={projectId}
asset={asset}
annotations={showLabels ? (annotations.data ?? []) : []}
- colorOf={colorOf}
+ classes={[...declared.values()]}
/>
@@ -205,12 +194,12 @@ function Picture({
projectId,
asset,
annotations,
- colorOf,
+ classes,
}: {
readonly projectId: string;
readonly asset: DatasetAsset;
readonly annotations: readonly WireAnnotation[];
- readonly colorOf: (labelClass: string) => string;
+ readonly classes: readonly LabelClass[];
}): JSX.Element {
const width = asset.width;
const height = asset.height;
@@ -231,82 +220,22 @@ function Picture({
);
}
- // The stage scales its stroke variables with the zoom; a static viewer has no
- // zoom, so the stroke is a fraction of the picture's width instead, and stays
- // the same apparent weight however large the dialog draws it.
- const stroke = Math.max(1, (STROKE_PX * width) / 800);
- const strokeVars = {
- "--vs-stroke": `${stroke}px`,
- "--vs-stroke-selected": `${stroke}px`,
- } as CSSProperties;
-
return (
-
-
- {(src) => (
-
- )}
-
-
- {annotations.map((annotation) => (
-
- ))}
-
-
- );
-}
-
-function Shape({
- annotation,
- color,
-}: {
- readonly annotation: WireAnnotation;
- readonly color: string;
-}): JSX.Element | null {
- const geometry = geometryOf(annotation);
- if (geometry === null || geometry.type === "classification_tag") return null;
- return (
-
- {geometry.type === "bbox" ? (
-
- ) : geometry.type === "polygon" ? (
-
- ) : (
-
+
+ {(src) => (
+
)}
-
+
);
}
-/** `null` for a geometry the engine does not draw — a stored shape is never a reason to fail the page. */
-function geometryOf(annotation: WireAnnotation): Geometry | null {
- try {
- return parseGeometry(annotation.geometry);
- } catch {
- return null;
- }
-}
-
function Metadata({ asset }: { readonly asset: DatasetAsset }): JSX.Element {
const rows: [string, string][] = [
[