PolyDraw is an SVG polygon annotation editor built with React 18, TypeScript, Vite, and Tailwind CSS. Users upload an image, draw polygons over it, and export the annotations in multiple formats. This document explains the architecture, features, and conventions so that any developer or agent can locate the right file for any change and understand the invariants to preserve.
src/
App.tsx # Root component -- composes hooks, wires mouse/keyboard events, renders layout
main.tsx # ReactDOM entry point
index.css # Tailwind directives and global styles
components/
Canvas/
Canvas.tsx # Main canvas container -- renders image, shapes, grid, cursor logic
PathOverlay.tsx # SVG overlay for path-testing visualization (color-coded dots, connecting lines)
index.ts # Barrel export
UI/
Button.tsx # Reusable button (variants: primary/secondary/danger/ghost, sizes: sm/md/lg)
Input.tsx # Multi-type input (text/number/range/checkbox/file)
index.ts
Widgets/
ViewControlsWidget.tsx # Undo/redo, zoom, opacity slider, clear-all
ExportWidget.tsx # Coordinate export (Python format + SVG string) with inline editing
ExportDropdown.tsx # Image export dropdown (PNG/JPEG/SVG) + copy-to-clipboard
JsonSchemaWidget.tsx # JSON zone schema display/edit with debounced serialization
PathTestingPanel.tsx # Path testing results panel (point list, manual text editing, export)
SimplificationPanel.tsx # Polygon simplification UI (RDP algorithm with preview)
PropertiesWidget.tsx # Shape property editor
ToolbarWidget.tsx # Drawing tool buttons
CoordinatesWidget.tsx # Coordinate display widget
ZoneTypeLayerPanel.tsx # Zone type CRUD, visibility toggles, color management
index.ts
hooks/
useCanvas.ts # Canvas state: zoom, pan, image upload, viewport transforms
useShapes.ts # Shape CRUD, two-tier undo/redo, DOM lifecycle
useHistory.ts # Snapshot undo/redo stack (max 50 entries)
useTools.ts # Tool selection, shift key, point dragging state
useKeyboardShortcuts.ts # Global keyboard event handler
useZoneTypes.ts # Zone type categories with visibility
usePathTesting.ts # Freehand path drawing + containment testing
index.ts
types/
shapes.ts # Point, ShapeStyle, BaseShape, PolygonShape, Shape union, DraggedPoint
canvas.ts # CanvasState, CanvasSettings, ImageInfo, ViewType
tools.ts # ToolType, ToolState, PathTestPoint, PathTestingState
zones.ts # ZoneType, DEFAULT_ZONE_TYPES
index.ts
utils/
coordinateUtils.ts # Mouse-to-canvas transform, edge snapping, line straightening, normalize/denormalize
shapeUtils.ts # Shape factories, hit detection, bounding box, ID generation
shapeRenderer.ts # SVG rendering factory (PolygonRenderer, CircleRenderer, ShapeRendererFactory)
geometryUtils.ts # Ray-casting point-in-polygon, point-to-segment distance, containment checks, RDP simplification
parseUtils.ts # Python/SVG coordinate string parsers and generators
exportUtils.ts # Image export (PNG/JPEG/SVG), clipboard copy, offscreen canvas composition
zoneUtils.ts # JSON zone schema serialization/deserialization, debounced serializer
pathParsingUtils.ts # Path text parsing and formatting for the path tester
canvasUtils.ts # Canvas helper utilities
circleUtils.ts # Circle-specific geometry helpers
imageLoadUtils.ts # Image loading benchmark (4 methods compared)
nameGenerator.ts # Auto-naming for new shapes
index.ts
tests/
setup/
setup.ts # Global test setup, DOM mocks
test-utils.tsx # Custom render helpers for React Testing Library
components/Canvas/PathOverlay.test.tsx
components/UI/Button.test.tsx
components/UI/Input.test.tsx
hooks/useCanvas.test.ts
hooks/useKeyboardShortcuts.test.ts
hooks/useTools.test.ts
utils/canvasUtils.test.ts
utils/circleUtils.test.ts
utils/coordinateUtils.test.ts
utils/exportUtils.test.ts
utils/imageLoadUtils.test.ts
utils/parseUtils.test.ts
utils/shapeRenderer.test.ts
utils/shapeUtils.test.ts
App.tsx composes six specialized hooks. Each hook owns a single domain and exposes a clean return interface:
App.tsx
useCanvas() -- zoom, pan, image upload, container refs
useShapes() -- shape array, current shape, undo/redo, CRUD
useTools() -- tool selection, shift key, dragging state
useZoneTypes() -- zone type CRUD, visibility toggles
usePathTesting() -- freehand path + containment results
useKeyboardShortcuts() -- global keydown/keyup listener (receives callbacks from above hooks)
All canvas mouse events (down, move, up) are handled by a single event delegation handler in App.tsx, not on individual shapes. The handler branches on the current tool and delegates to the appropriate hook methods.
Hooks use useRef to make current state available to callbacks without triggering re-renders:
const shapesRef = useRef<Shape[]>([]);
shapesRef.current = shapes;This is used in useShapes (for snapshot serialization) and usePathTesting (for last-point tracking). Callbacks read from the ref to get the latest value without adding the state to dependency arrays.
Shapes use a dual-DOM approach:
- SVG elements render the shape fill, stroke, and name label. Each shape gets its own
<svg>container with a shape element and a<text>name element inside. - HTML
<div>elements render the draggable point handles. These are positioned absolutely over the canvas and usedata-point="true"for identification.
Point handles are HTML divs (not SVG circles) because:
- They need reliable z-index layering above all SVG content
- CSS transitions (
hover:scale-150) work more predictably - Hit detection for drag-start is simpler with native DOM events
Dynamic style properties (fill color, opacity, stroke color) are set via inline SVG attributes in shapeRenderer.ts, not via CSS classes. This prevents Tailwind CSS specificity conflicts from overriding dynamic values. The only CSS class used on shape elements is stroke-2 as a fallback, with vectorEffect: 'non-scaling-stroke' applied inline to keep stroke width constant during zoom.
The undo/redo system has two tiers that operate independently depending on whether the user is currently drawing.
Active when currentShape !== null (a polygon is being drawn).
- Undo (
Ctrl+Z): Removes the last point fromcurrentShape.points, pushes it ontodrawingRedoStackRef, removes the corresponding DOM point handle, and updates the SVG display. - Redo (
Ctrl+Shift+Z): Pops fromdrawingRedoStackRef, creates a new point handle, appends tocurrentShape.points, updates display. - Edge case -- undoing the first point: If
points.length <= 1, the entire shape is destroyed, removed from state, andhistory.discardLast()is called to remove the pre-creation snapshot from the main stack (prevents a no-op undo entry).
Active when currentShape === null (no polygon is being drawn).
useHistorymaintains two stacks (pastRefandfutureRef) of serialized shape arrays, capped at 50 entries.- Undo: Serializes current shapes, pushes onto
futureRef, pops frompastRef, rebuilds all shapes from the popped snapshot. - Redo: Serializes current shapes, pushes onto
pastRef, pops fromfutureRef, rebuilds. - Rebuilding destroys all current DOM elements and recreates them from snapshots via
rebuildShapeFromSnapshot().
saveSnapshot()must be called before any destructive operation (delete shape, clear all, start new polygon, start point drag).addPointToShape()andcompleteCurrentShape()both cleardrawingRedoStackRef(just like typing new text clears redo in a text editor).completeCurrentShape()callshistory.discardLast()if the polygon has fewer than 3 points (incomplete polygon is not a valid undo target).- Snapshots are deep copies --
serializeShape()copies allPointobjects and thecolorobject to prevent reference sharing.
| Concern | File | Key Functions |
|---|---|---|
| Tier 1 logic | src/hooks/useShapes.ts |
handleUndo(), handleRedo() (the if (currentShape) branches) |
| Tier 2 stack | src/hooks/useHistory.ts |
pushState(), undo(), redo(), discardLast() |
| Serialization | src/hooks/useShapes.ts |
serializeShape(), serializeCurrentShapes() |
| Rebuild | src/hooks/useShapes.ts |
rebuildShapeFromSnapshot(), rebuildFromSnapshots() |
| Snapshot triggers | src/hooks/useShapes.ts |
saveSnapshot() -- called before removeShape, clearAllShapes, startNewPolygon |
| Keyboard wiring | src/hooks/useKeyboardShortcuts.ts |
onUndo, onRedo callbacks |
startNewPolygon(point, canvasRef) in useShapes.ts:
- Calls
saveSnapshot()to record pre-creation state. - Clears
drawingRedoStackRef. - Creates shape object via
createPolygonShape()fromshapeUtils.ts. - Creates SVG via
createShapeSVG()fromshapeRenderer.ts. - Creates a dashed preview line (
<line>element) and appends to the SVG. - Creates the first point handle (HTML div) and appends to canvas.
- Adds shape to state and sets it as
currentShape.
addPointToShape(point, isShiftPressed) in useShapes.ts:
- Clears drawing redo stack.
- Applies
straightenLine()if Shift is held (snaps to 0/45/90/135/180/225/270/315 degrees). - Creates a new point handle div.
- Updates shape state and calls
updateShapeDisplay().
completeCurrentShape() in useShapes.ts:
- Clears drawing redo stack.
- Validates minimum 3 points -- if fewer, destroys the shape and calls
history.discardLast(). - Removes the preview line from the SVG.
- Sets
currentShapetonull.
Point dragging is handled in App.tsx's handleCanvasMouseMove:
- On mouse-down with select tool,
findPointAt()detects if a point handle is under the cursor (15px threshold, scaled by zoom). saveSnapshot()is called, thensetDraggingPoint(true, point).- On mouse-move, the dragged point's coordinates are updated via
updateShapePoints(), which callsupdateShapeDisplay(). - On mouse-up, dragging stops.
removeShape(shape) in useShapes.ts:
- Calls
saveSnapshot(). - Removes SVG and all point handles from DOM via
destroyShapeDOM(). - Filters shape from state array.
All events are handled by three callbacks in App.tsx: handleCanvasMouseDown, handleCanvasMouseMove, handleCanvasMouseUp. Each branches on the current tool:
Polygon Tool:
- Click creates/extends a polygon.
- Clicking near the first point (15px / scale threshold) completes it.
- Clicking near an existing point (detected by
findNearbyPointOnShape()) is blocked. - Mouse-move updates the dashed preview line and highlights points.
Select Tool:
- Click on a point handle starts dragging (calls
findPointAt()). - Click on empty space starts canvas panning.
- Mouse-move updates point position or canvas offset.
Path Tester:
- Mouse-down starts path recording.
- Mouse-move adds points (minimum 8px spacing, maximum 1000 points).
- Mouse-up completes the path and runs containment checks.
getMousePosition() in coordinateUtils.ts converts screen coordinates to canvas coordinates:
canvasX = (clientX - containerRect.left - offsetX) / scale
canvasY = (clientY - containerRect.top - offsetY) / scale
Optional edge snapping is applied if enabled (snaps to image boundaries within the configured threshold).
Mouse wheel zoom in useCanvas.ts keeps the point under the cursor fixed:
- Calculate the canvas coordinate under the mouse.
- Apply the zoom factor to the scale.
- Recalculate the offset so the same canvas coordinate remains under the mouse.
Point handle states are managed via inline styles in App.tsx's mouse-move handler:
| State | Border Color | Box Shadow | Scale | Trigger |
|---|---|---|---|---|
| Normal | blue-500 (CSS) | none | 1x | Default |
| Hoverable first point | #22c55e (green) |
green glow | 1.6x | Can close polygon (>= 3 points) |
| Blocking overlap | #ef4444 (red) |
red glow | 1.3x | Too close to existing point |
| Selected | CSS border-red-500 ring |
-- | -- | After click in select tool |
| Format | Generator | Parser | File |
|---|---|---|---|
| Python tuples | generatePythonString() (in ExportWidget) |
parsePythonString() |
src/utils/parseUtils.ts |
| SVG string | generateSVGString() |
parseSVGString() |
src/utils/parseUtils.ts |
| JSON zone schema | serializeToZoneSchema() |
parseZoneSchema() |
src/utils/zoneUtils.ts |
| Normalized (0-1) | Same functions with normalize: true |
Same with denormalization | Same files |
Python format example:
# Polygon 1
shape_1 = [(100, 200), (150, 250), (200, 200)]SVG string example:
# Polygon 1
100 200 150 250 200 200
JSON zone schema:
{
"zones": [
{ "name": "Zone 1", "zone_type": "region", "points": "100 200 150 250 200 200" }
],
"zone_types": [
{ "id": "region", "name": "region", "color": "#3b82f6" }
]
}Parsing validates a minimum of 3 points per polygon. Both Python and SVG parsers use regex extraction and filter out comment lines starting with #.
| Format | Function | File |
|---|---|---|
| PNG | exportAsImage(imageInfo, shapes, { format: 'png' }) |
src/utils/exportUtils.ts |
| JPEG | exportAsImage(imageInfo, shapes, { format: 'jpeg', quality: 0.92 }) |
src/utils/exportUtils.ts |
| SVG | exportAsSVG(imageInfo, shapes) |
src/utils/exportUtils.ts |
| Clipboard | copyImageToClipboard(imageInfo, shapes) |
src/utils/exportUtils.ts |
All image exports use renderImageWithShapes() which creates an offscreen canvas, draws the base image, then draws all polygon fills and strokes on top. SVG export embeds the base image as a base64 <image> element.
The path testing panel (PathTestingPanel.tsx) exports test results in JSON, CSV, and plain text via buttons in its UI. The data includes each point's coordinates, containment status, and the names of containing polygons.
Reduces polygon complexity using the Ramer-Douglas-Peucker (RDP) algorithm while preserving shape characteristics.
The RDP algorithm works recursively:
- Draw a line between the first and last points of the polygon
- Find the point with maximum perpendicular distance from this line
- If the distance exceeds the tolerance threshold, split at that point and recurse on both segments
- Otherwise, remove all intermediate points
Core algorithm in src/utils/geometryUtils.ts:
// Main API - returns simplified points with metadata
simplifyPolygon(points: Point[], tolerance: number): SimplificationResult
// Generates preview data (kept vs removed points)
previewSimplification(points: Point[], tolerance: number): PreviewResult
// Internal helper - calculates perpendicular distance
perpendicularDistance(point: Point, lineStart: Point, lineEnd: Point): number
// Internal recursive algorithm
rdpSimplify(points: Point[], startIndex: number, endIndex: number,
tolerance: number, keepIndices: Set<number>): voidControlled by SimplificationPanel.tsx in the coordinates section of App.tsx:
- Tolerance Slider: 1-50 pixel range
- Preview Toggle: Shows/hides overlay visualization
- Point Count Display: "47 pts → 12 pts"
- Apply Button: Commits changes with undo support (calls
shapes.saveSnapshot()) - Reset Button: Restores original points from backup
Rendered in Canvas.tsx as an SVG overlay:
- Blue dashed polygon outline shows the simplified shape
- Blue filled circles (5px) mark points that will be kept
- Red hollow circles (4px) mark points that will be removed
- Updates with 50ms debounce to prevent excessive recalculation
- Minimum 3 points always preserved (valid polygon requirement)
- Triangle polygons (3 points) cannot be simplified
- Zero or negative tolerance returns original polygon
- Deep copies prevent mutation of source data
- Undo/redo integration via snapshot system
| Concern | File | Key Functions/Components |
|---|---|---|
| RDP algorithm | src/utils/geometryUtils.ts |
simplifyPolygon(), rdpSimplify(), perpendicularDistance() |
| UI controls | src/components/Widgets/SimplificationPanel.tsx |
Slider, toggle, buttons, state management |
| Preview rendering | src/components/Canvas/Canvas.tsx |
SVG overlay with circles and dashed polygon |
| Integration | src/App.tsx |
State wiring, handleApplySimplification() |
| Tests | tests/utils/geometryUtils.test.ts |
19 test cases for edge cases |
- Complexity: O(n²) worst case, O(n log n) typical
- Typical Reduction: 50-80% fewer points
- Debouncing: 50ms delay on slider updates
- Memory: Creates deep copies to prevent reference sharing
Activated by pressing T or clicking the Test button. Users draw a freehand path over the canvas and each point is tested against all polygons.
- Bounding box rejection --
calculateShapeBounds()skips polygons where the point is clearly outside. - Edge detection --
isPointOnEdge()checks if the point is within 3px of any polygon edge usingpointToSegmentDistance(). - Ray casting --
isPointInPolygon()counts ray intersections. Odd = inside, even = outside.
- Minimum 8px spacing between recorded points (prevents oversampling during fast mouse movement).
- Maximum 1000 points per path.
- Manual text editing is supported via a textarea in the panel.
- Green circle: point is inside a polygon
- Red circle: point is outside all polygons
- Blue circle: point is on a polygon edge
| Concern | File |
|---|---|
| Hook state + drawing logic | src/hooks/usePathTesting.ts |
| Geometry algorithms | src/utils/geometryUtils.ts |
| Text parsing/formatting | src/utils/pathParsingUtils.ts |
| Visual overlay | src/components/Canvas/PathOverlay.tsx |
| Results panel UI | src/components/Widgets/PathTestingPanel.tsx |
Zone types categorize polygons (default types: region, exclusion, highlight). Each type has a name, color, and visibility toggle.
useZoneTypes.ts manages the zone type array with CRUD operations:
addZoneType(name, color)-- creates a new type with a generated ID.updateZoneType(id, updates)-- modifies name or color.deleteZoneType(id)-- removes the type (UI confirms if shapes reference it).toggleVisibility(id)-- flipsisVisible, which hides/shows all shapes of that type.
App.tsx has a useEffect that calls updateShapesVisibility() whenever zoneTypes or shapes change. This function sets display: none on SVG elements and point handles of shapes whose zone type is hidden.
The JsonSchemaWidget serializes all shapes and zone types into the JSON format shown above. On import, it replaces both shapes and zone types atomically. Serialization is debounced (250ms) using requestIdleCallback to avoid blocking the UI.
All shortcuts are handled in useKeyboardShortcuts.ts via a global keydown listener. Shortcuts are ignored when focus is in a text input or textarea.
| Key | Action | Context |
|---|---|---|
Ctrl+Z / Cmd+Z |
Undo | Always (branches to Tier 1 or Tier 2) |
Ctrl+Shift+Z / Cmd+Y |
Redo | Always |
Ctrl+C / Cmd+C |
Copy image to clipboard | When not in text input |
Delete |
Remove selected point | Select tool with a selected point |
Escape |
Complete/cancel polygon, or exit path tester | Depends on current tool |
T |
Toggle path tester mode | Always (not in text input) |
C |
Clear path | Only when path tester is active |
Shift (hold) |
Straighten lines to 0/45/90/135/180/225/270/315 degrees | During polygon drawing |
For annotating 360-degree panoramic images split into top and bottom halves.
- A red dashed separator line is drawn at the vertical midpoint (
Canvas.tsx). - Edge snapping works per-half: top half snaps to y=0 and y=midpoint; bottom half snaps to y=midpoint+1 and y=height.
- Points cannot cross the midline boundary (constrained in
snapToImageEdges()incoordinateUtils.ts). - Selected via a dropdown in the sidebar (
canvasSettings.viewType).
The type system already defines ShapeType = 'polygon' | 'circle' | 'rectangle' | 'line' | 'ellipse', but only polygon has full implementation. To add support for another type:
-
Types (
src/types/shapes.ts): The interface already exists (e.g.,CircleShape). Verify it has the fields you need. -
Renderer (
src/utils/shapeRenderer.ts): ACircleRendererclass already exists and is registered inShapeRendererFactory. For other types, create a new class implementingShapeRendererwithcreateSVGElement(),updatePoints(), andapplyStyle(), then register it:ShapeRendererFactory.registerRenderer('rectangle', new RectangleRenderer()). -
Shape creation (
src/hooks/useShapes.ts): Add a method analogous tostartNewPolygon()-- e.g.,startNewCircle(center, canvasRef). It should callsaveSnapshot(), create the shape object, callcreateShapeSVG(), create point handles, and setcurrentShape. -
Tool wiring (
src/hooks/useTools.ts+App.tsx): Add the tool toToolType, add a toolbar button inApp.tsx, and add a branch inhandleCanvasMouseDown/handleCanvasMouseMovefor the new tool's interaction model. -
Serialization (
src/hooks/useShapes.ts): EnsureserializeShape()andrebuildShapeFromSnapshot()handle any new fields (e.g.,radiusfor circles).
-
Coordinate format: Add parser and generator functions in
src/utils/parseUtils.tsfollowing the pattern ofparsePythonString()/generateSVGString(). Ensure the parser validates a minimum of 3 points. -
Wire into UI: Add a tab or section in
src/components/Widgets/ExportWidget.tsxfollowing the existing Python/SVG tab pattern. Both tabs use a code display with an edit mode (textarea + Apply/Cancel buttons). -
Image format: Add a case in
src/utils/exportUtils.tsfollowing theexportAsImage()/exportAsSVG()pattern. Add a menu item insrc/components/Widgets/ExportDropdown.tsx.
Any operation that destructively modifies shapes should call saveSnapshot() before the modification. This is already done in:
startNewPolygon()-- before creating the new shaperemoveShape()-- before deletingclearAllShapes()-- before clearinghandleCanvasMouseDowninApp.tsx-- before starting a point drag
To add a new undoable operation, call shapes.saveSnapshot() before making changes.
For drawing-mode undo behavior (Tier 1), modify the if (currentShape) branches in handleUndo() and handleRedo() in useShapes.ts.
If canceling an operation that already pushed a snapshot (e.g., incomplete polygon), call history.discardLast() to clean up the stack.
- Vitest 3.2.4 -- test runner
- jsdom -- browser environment simulation
- React Testing Library -- component rendering and assertions
@testing-library/user-event-- user interaction simulation
npm test # Watch mode
npm run test:run # Single run
npm run test:coverage # With V8 coverage report- Test files live in
tests/mirroring thesrc/structure:tests/utils/parseUtils.test.tstestssrc/utils/parseUtils.ts. - Pure utility functions are tested with direct imports and assertions.
- Hooks are tested with
renderHook()from React Testing Library. - Components are tested with
render()+screenqueries +userEventinteractions. - Global setup is in
tests/setup/setup.ts(DOM mocks for canvas, SVG, clipboard, etc.).
- Create a file at
tests/<category>/<name>.test.ts(x). - Import the module under test.
- Use
describe/itblocks withexpectassertions. - For hooks, wrap calls in
act()from React Testing Library. - For components that need custom providers, use helpers from
tests/setup/test-utils.tsx.
npm run build # Vite production build
npm run lint # ESLint| File | Purpose |
|---|---|
vite.config.ts |
React plugin, host 0.0.0.0, base ./, excludes lucide-react from dep optimization |
vitest.config.ts |
jsdom environment, V8 coverage, global setup file |
tsconfig.json |
Composite config referencing tsconfig.app.json + tsconfig.node.json |
tailwind.config.js |
Content paths for all HTML and React files |
eslint.config.js |
TypeScript ESLint with React hooks and refresh plugins |
Dockerfilebuilds the app and serves via Nginx.nginx.confconfigures SPA routing (all paths fall through toindex.html).docker-compose.ymlorchestrates the container.
.github/workflows/ci.yml-- runs tests on push/PR against Node 18, 20, 22..github/workflows/deploy.yml-- production deployment pipeline.
| Decision | Rationale |
|---|---|
| Blob URL for image loading | 60-70% faster than base64 data URLs; lower memory; cleanup via URL.revokeObjectURL() |
| HTML divs for point handles | Reliable z-index over SVG; CSS transitions; simpler hit detection |
| Inline SVG attributes over CSS classes | Prevents Tailwind specificity from overriding dynamic colors |
| Two-tier undo/redo | Per-point undo during drawing feels instant; snapshot undo handles completed shapes without per-field granularity |
| Single event handler on canvas | Better performance than per-shape listeners; scales to many shapes |
vectorEffect: 'non-scaling-stroke' |
Stroke width stays constant regardless of zoom level |
| Debounced JSON serialization | Uses requestIdleCallback with 250ms delay to avoid blocking UI during typing |
| Deep-copy snapshots | Prevents reference sharing between history entries; each snapshot is independently mutable |
| Factory pattern for renderers | ShapeRendererFactory makes adding new shape types a single-class addition |