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 ( +
+ {alt} + +
+ ); +} + +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) => ( - {alt} - )} - - -
- ); -} - -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][] = [ [