Skwik/src/components/CorrectedImageViewer.vue
Samuel Prevost 6dc5454d46
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled
refactor(crop): tighten architecture + fix zoom-after-crop
Architectural debt addressed in one pass:

- CropViewer drops its five local refs (rotationDeg, cropLeft/Top/Right
  /Bottom) in favour of computed views over `store.cropRotate`. Drag and
  rotation handlers write straight to the store via thin setters; the
  store is now the canonical source of truth. localStorage is flushed on
  commit boundaries (drag end, rotation change, unmount, Next), not on
  every pointermove.
- `ImagePreTransform` deleted from `src/types/index.ts`. CorrectedImage-
  Viewer now takes `crop?: { state: CropRotateState; srcW; srcH }` and
  derives the pixel-space affine internally via a memoised `computed`.
  Cuts the second reactive ref in MeasureViewer and removes the
  redundant "type that exists only to ferry derived numbers across a
  prop boundary."
- `crop-transform.ts` is now pure geometry. The DOM-bound
  `renderRotatedCropped` moved to a new `src/lib/crop-render.ts`. The
  pure module is now safe to import from a worker / test without a
  canvas mock.

Bug fix shipped in the same commit because it fell out of the same
analysis:

- Zoom (wheel + pinch) anchored on a measurement-space point returned
  by `screenToImg`, but `viewOffset = screen - bitmap * scale` expects a
  bitmap-space point. With identity crop the two were equal, so the bug
  was invisible until the user cropped — at that point each zoom step
  shifted the image far off-screen ("canvas goes blank/black"). Pan was
  fine because it never converted screen↔image. Fix: a tiny
  `screenToBitmap` helper used by `onWheel` and the pinch branch of
  `onTouchMove`. Measurement placement / drag still go through the
  full `screenToImg` so coords stay in the canonical frame.
2026-05-01 00:29:24 +02:00

2790 lines
98 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { nanoid } from "nanoid"
import type { CropRotateState, Point } from "@/types"
import type {
LineMeasurement,
RectMeasurement,
EllipseMeasurement,
CircleMeasurement,
AngleMeasurement,
Measurement,
} from "@/types/measurements"
import { getDatumColor } from "@/lib/datums"
import { useAppStore } from "@/stores/app"
import { loadMeasurements, saveMeasurements } from "@/lib/measurement-cache"
import { loadZoom, saveZoom } from "@/lib/zoom-cache"
import { rotatedBboxSize, cropPixels } from "@/lib/crop-transform"
// `crop` is an optional pre-transform from "deskewed-image space" (the
// original untransformed measurement coordinate frame) to the bitmap
// space of the image actually shown by `imageUrl`. Used by the Crop &
// Rotate step so measurements stay anchored to the deskewed image
// while the user views a rotated + cropped sub-bitmap. The mapping is:
//
// measurement_pt → rotate around (srcW/2, srcH/2) by rotationDeg
// → translate by (rotW/2, rotH/2)
// → subtract (cropX, cropY) → bitmap_pt
//
// When omitted, behaviour is identity. The viewer does NOT rewrite
// stored measurement coordinates when the transform changes; it only
// adjusts the draw-time projection (option (b) from the task brief),
// so changing the crop/rotation never invalidates persisted
// measurements.
const props = defineProps<{
imageUrl: string
scalePxPerMm: number
/** Cropping/rotation transform applied between the deskewed image
* and the bitmap pointed at by `imageUrl`. The viewer derives the
* pixel-space affine internally — callers only supply the
* canonical state plus the source dimensions of the deskewed
* bitmap. Omit for the legacy identity behaviour. */
crop?: { state: CropRotateState; srcW: number; srcH: number }
}>()
// Emit when the measurement set changes (add / mutate / delete) so the
// parent can refresh the recent-uploads gallery thumbnail. Fired
// debounced (via the same `measurements` deep watcher that persists to
// localStorage) so high-frequency interactions like dragging an
// endpoint don't thrash the encoder.
const emit = defineEmits<{
(event: "measurements-changed"): void
}>()
const isMobile = useMediaQuery("(max-width: 767px)")
// Mirror the datum-editor precedent: leave more vertical room for the mobile
// toolbar/chrome than desktop. Keep the canvas and the side list the same
// height so they align on desktop.
const canvasHeightClass = computed(() =>
isMobile.value ? "h-[calc(100vh-14rem)]" : "h-[calc(100vh-12rem)]",
)
const containerRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const overlayRef = ref<HTMLCanvasElement | null>(null)
const img = ref<HTMLImageElement | null>(null)
const imgLoaded = ref(false)
// View state
const viewScale = ref(1)
const viewOffsetX = ref(0)
const viewOffsetY = ref(0)
// Tool state
type ToolMode = "none" | "line" | "rectangle" | "ellipse" | "circle" | "angle"
const activeTool = ref<ToolMode>("none")
const showGrid = ref(false)
const gridSpacingMm = ref(10)
// When on, line/angle placements and endpoint drags snap their direction to
// the nearest 45° (0/45/90/135…) relative to the fixed endpoint or angle
// vertex. Length is preserved; only direction is constrained.
const snapToAngle = ref(false)
// Full-screen mode: render the viewer as a fixed-position overlay covering
// the viewport. The ResizeObserver picks up the new container size and
// re-fits the image; nothing else needs to change.
const isFullscreen = ref(false)
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
// Measurement types live in `@/types/measurements` so the cache module and
// other consumers can share them. Geometry is in image space so it stays
// invariant under pan/zoom and survives redraws without reprojection.
const store = useAppStore()
const measurements = ref<Measurement[]>([])
const selectedId = ref<string | null>(null)
// Monotonically increasing counter so deleting a measurement doesn't recolor
// the remaining ones. Each new measurement claims the next palette slot.
// Reset on cache load to `max(loaded.colorIndex) + 1` so newly-added
// measurements continue the sequence rather than reusing old colors.
let colorCounter = 0
function seedFromCache() {
const hash = store.fileHash
if (!hash) {
measurements.value = []
selectedId.value = null
colorCounter = 0
return
}
const loaded = loadMeasurements(hash)
if (loaded && loaded.length > 0) {
measurements.value = loaded
selectedId.value = null
let maxIdx = -1
for (const m of loaded) {
if (m.colorIndex > maxIdx) maxIdx = m.colorIndex
}
colorCounter = maxIdx + 1
} else {
measurements.value = []
selectedId.value = null
colorCounter = 0
}
}
// In-progress placement points (image space) while a placement tool is active.
const placementPoints = ref<Point[]>([])
// Cursor position in image space for the live preview of an in-progress
// placement. Null when the pointer is off-canvas.
const placementCursor = ref<Point | null>(null)
// Touch/pan state
let isPanning = false
let panStart = { x: 0, y: 0 }
let lastPinchDist = 0
// Drag state for moving/reshaping committed measurements. We don't gate
// drag-start on a movement threshold — handles begin tracking the cursor
// on the very first move event so positioning feels precise (matches
// Konva's behaviour in the datum editor). A pure click without movement
// never enters pointerMove, so selection on its own remains drift-free.
type DragMode = "none" | "move" | "handle"
interface DragState {
mode: DragMode
measurementId: string
// For "handle" drag: which handle of the measurement we grabbed.
handleKey: string | null
// Image-space coord where the drag started (cursor position).
startImg: Point
// Snapshot of the measurement at drag start, for delta-based updates.
startSnapshot: Measurement
}
let dragState: DragState | null = null
function loadImg() {
const image = new Image()
image.onload = () => {
img.value = image
imgLoaded.value = true
fitToContainer()
// After auto-fit, prefer a previously-saved zoom/pan if the
// values still place the image inside the container — protects
// against stale cache entries (different image dims) that would
// otherwise leave the viewer staring at empty canvas.
const hash = store.fileHash
if (hash) {
const cached = loadZoom(hash)
if (cached && isZoomReasonable(cached)) {
viewScale.value = cached.viewScale
viewOffsetX.value = cached.viewOffsetX
viewOffsetY.value = cached.viewOffsetY
}
}
redraw()
}
image.src = props.imageUrl
}
// A cached zoom/pan is "reasonable" if the image's bounding box still
// intersects the canvas at all under that transform. Catches the
// degenerate case where the cache outlived an image dimension change.
function isZoomReasonable(z: {
viewScale: number
viewOffsetX: number
viewOffsetY: number
}): boolean {
if (!Number.isFinite(z.viewScale) || z.viewScale <= 0) return false
if (!Number.isFinite(z.viewOffsetX) || !Number.isFinite(z.viewOffsetY))
return false
const c = containerRef.value
const i = img.value
if (!c || !i) return false
const left = z.viewOffsetX
const top = z.viewOffsetY
const right = left + i.naturalWidth * z.viewScale
const bottom = top + i.naturalHeight * z.viewScale
return right > 0 && bottom > 0 && left < c.clientWidth && top < c.clientHeight
}
function fitToContainer() {
const c = containerRef.value
const i = img.value
if (!c || !i) return
const cw = c.clientWidth
const ch = c.clientHeight
if (canvasRef.value) {
canvasRef.value.width = cw
canvasRef.value.height = ch
}
if (overlayRef.value) {
overlayRef.value.width = cw
overlayRef.value.height = ch
}
const fit = Math.min(cw / i.naturalWidth, ch / i.naturalHeight) * 0.95
viewScale.value = fit
viewOffsetX.value = (cw - i.naturalWidth * fit) / 2
viewOffsetY.value = (ch - i.naturalHeight * fit) / 2
}
function redraw() {
drawImage()
drawOverlay()
}
function drawImage() {
const canvas = canvasRef.value
const image = img.value
if (!canvas || !image) return
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
ctx.translate(viewOffsetX.value, viewOffsetY.value)
ctx.scale(viewScale.value, viewScale.value)
ctx.drawImage(image, 0, 0)
ctx.restore()
}
function drawOverlay() {
const canvas = overlayRef.value
const image = img.value
if (!canvas || !image) return
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.save()
if (showGrid.value) {
drawGrid(ctx, image)
}
const rt = makeLiveCtx()
// Draw unselected first (faint) so the selected measurement always sits
// on top with full opacity and its handles aren't occluded.
for (const m of measurements.value) {
if (m.id === selectedId.value) continue
drawMeasurement(ctx, m, false, rt)
}
const selected = measurements.value.find((m) => m.id === selectedId.value)
if (selected) drawMeasurement(ctx, selected, true, rt)
// Labels go in a second pass: collision-resolve all positions across
// the full set, then paint. Resolving after geometries means labels
// never get hidden by lines drawn after them, and one shared resolver
// keeps unselected and selected labels from overlapping each other.
const labelPositions = resolveLabelPositions(ctx, measurements.value, rt)
for (const m of measurements.value) {
if (m.id === selectedId.value) continue
const pos = labelPositions.get(m.id)
if (pos) drawLabelAt(ctx, m, false, rt, pos)
}
if (selected) {
const pos = labelPositions.get(selected.id)
if (pos) drawLabelAt(ctx, selected, true, rt, pos)
}
// Placement preview overlaying everything, in the active tool's color
// slot (= next palette slot the new measurement will claim).
if (activeTool.value !== "none" && placementPoints.value.length > 0) {
drawPlacementPreview(ctx)
}
ctx.restore()
}
function drawGrid(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
) {
const spacingPx = gridSpacingMm.value * props.scalePxPerMm
if (spacingPx <= 0) return
const w = image.naturalWidth
const h = image.naturalHeight
ctx.save()
ctx.translate(viewOffsetX.value, viewOffsetY.value)
ctx.scale(viewScale.value, viewScale.value)
ctx.strokeStyle = "rgba(255, 255, 255, 0.15)"
ctx.lineWidth = 1 / viewScale.value
for (let x = 0; x <= w; x += spacingPx) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, h)
ctx.stroke()
}
for (let y = 0; y <= h; y += spacingPx) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
ctx.stroke()
}
ctx.strokeStyle = "rgba(255, 255, 255, 0.35)"
ctx.lineWidth = 1.5 / viewScale.value
const major = spacingPx * 5
for (let x = 0; x <= w; x += major) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, h)
ctx.stroke()
}
for (let y = 0; y <= h; y += major) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(w, y)
ctx.stroke()
}
ctx.fillStyle = "rgba(255, 255, 255, 0.6)"
const fontSize = Math.max(10, 12 / viewScale.value)
ctx.font = `${String(fontSize)}px monospace`
for (let x = major; x <= w; x += major) {
const mm = x / props.scalePxPerMm
ctx.fillText(mm.toFixed(0), x + 2 / viewScale.value, fontSize + 2 / viewScale.value)
}
for (let y = major; y <= h; y += major) {
const mm = y / props.scalePxPerMm
ctx.fillText(mm.toFixed(0), 2 / viewScale.value, y - 2 / viewScale.value)
}
ctx.restore()
}
// Render context for drawing measurements. The live overlay constructs one
// from the current view transform; the export path constructs its own so
// the same draw helpers can paint into an offscreen canvas at any scale.
// scale/offsetX/offsetY: the image→canvas affine to apply.
// strokeMul: multiplier on stroke widths, font sizes, and handle radii.
// 1 keeps the on-screen visual; >1 scales them up so they read at the
// same relative size when exporting at a higher pixel resolution.
// drawHandles: live overlay draws interactive control points; export
// suppresses them since they're UI, not annotation.
// drawSelectionDecorations: live overlay highlights the selected
// measurement with a white outline + dashed unselected siblings;
// export draws every measurement at full opacity, no dashing.
interface RenderCtx {
scale: number
offsetX: number
offsetY: number
strokeMul: number
drawHandles: boolean
drawSelectionDecorations: boolean
}
function makeLiveCtx(): RenderCtx {
return {
scale: viewScale.value,
offsetX: viewOffsetX.value,
offsetY: viewOffsetY.value,
strokeMul: 1,
drawHandles: true,
drawSelectionDecorations: true,
}
}
// Pre-derived components of the crop pre-transform. Cached because
// `imgPreTransform` is called per-vertex in inner draw loops and the
// trig + bbox math don't change while the user is just panning the
// canvas. Recomputed only when `props.crop` itself changes.
const cropDerived = computed(() => {
const c = props.crop
if (!c) return null
const rot = rotatedBboxSize(c.srcW, c.srcH, c.state.rotationDeg)
const px = cropPixels(c.state, rot)
const r = (c.state.rotationDeg * Math.PI) / 180
return {
cx: c.srcW / 2,
cy: c.srcH / 2,
cos: Math.cos(r),
sin: Math.sin(r),
halfRotW: rot.rotW / 2,
halfRotH: rot.rotH / 2,
cropX: px.cropX,
cropY: px.cropY,
}
})
// Project a measurement point from deskewed-image space into the
// shown bitmap's coordinate frame. Returns the input unchanged when
// no crop transform is configured — keeps the legacy non-cropped
// path pixel-identical.
function imgPreTransform(pt: Point): Point {
const d = cropDerived.value
if (!d) return pt
const dx = pt.x - d.cx
const dy = pt.y - d.cy
const rx = dx * d.cos - dy * d.sin + d.halfRotW
const ry = dx * d.sin + dy * d.cos + d.halfRotH
return { x: rx - d.cropX, y: ry - d.cropY }
}
// Inverse of `imgPreTransform`: bitmap-space → deskewed-image space.
// Used by `screenToImg` so pointer-driven placements / drags stay in
// the canonical measurement coordinate frame.
function imgPreTransformInverse(pt: Point): Point {
const d = cropDerived.value
if (!d) return pt
const rx = pt.x + d.cropX
const ry = pt.y + d.cropY
const dx = rx - d.halfRotW
const dy = ry - d.halfRotH
return {
x: dx * d.cos + dy * d.sin + d.cx,
y: -dx * d.sin + dy * d.cos + d.cy,
}
}
function imgToCtx(pt: Point, t: RenderCtx): Point {
const b = imgPreTransform(pt)
return {
x: b.x * t.scale + t.offsetX,
y: b.y * t.scale + t.offsetY,
}
}
function imgToScreen(pt: Point): Point {
const b = imgPreTransform(pt)
return {
x: b.x * viewScale.value + viewOffsetX.value,
y: b.y * viewScale.value + viewOffsetY.value,
}
}
function screenToImg(sx: number, sy: number): Point {
const b: Point = {
x: (sx - viewOffsetX.value) / viewScale.value,
y: (sy - viewOffsetY.value) / viewScale.value,
}
return imgPreTransformInverse(b)
}
// If `snapToAngle` is on, rotate `to` around `from` to the nearest 45°
// multiple while preserving |to - from|. Used by line + angle placement
// and endpoint dragging so the user can lay down clean orthogonal /
// diagonal references without aiming pixel-perfect with the cursor.
function maybeSnap45(from: Point, to: Point): Point {
if (!snapToAngle.value) return to
const dx = to.x - from.x
const dy = to.y - from.y
const r = Math.hypot(dx, dy)
if (r < 1e-6) return to
const STEP = Math.PI / 4
const snapped = Math.round(Math.atan2(dy, dx) / STEP) * STEP
return {
x: from.x + r * Math.cos(snapped),
y: from.y + r * Math.sin(snapped),
}
}
// Per-measurement dimensions, all in millimetres.
function lineLengthMm(m: LineMeasurement): number {
const dx = m.b.x - m.a.x
const dy = m.b.y - m.a.y
return Math.hypot(dx, dy) / props.scalePxPerMm
}
function ellipseAxesMm(m: EllipseMeasurement): { semiMajor: number; semiMinor: number } {
// Using |vA| and |vB| as semi-axes directly. This assumes the user drew
// roughly perpendicular conjugate axes, which is the common case on a
// deskewed image. A full Q = (M M^T)^{-1} eigendecomposition would be
// more accurate for skewed inputs but is overkill here.
const lenA = Math.hypot(m.axisEndA.x - m.center.x, m.axisEndA.y - m.center.y) / props.scalePxPerMm
const lenB = Math.hypot(m.axisEndB.x - m.center.x, m.axisEndB.y - m.center.y) / props.scalePxPerMm
return {
semiMajor: Math.max(lenA, lenB),
semiMinor: Math.min(lenA, lenB),
}
}
function circleRadiusMm(m: CircleMeasurement): number {
const dx = m.edge.x - m.center.x
const dy = m.edge.y - m.center.y
return Math.hypot(dx, dy) / props.scalePxPerMm
}
function angleDegrees(m: AngleMeasurement): number {
const ax = m.armA.x - m.vertex.x
const ay = m.armA.y - m.vertex.y
const bx = m.armB.x - m.vertex.x
const by = m.armB.y - m.vertex.y
const dot = ax * bx + ay * by
const cross = ax * by - ay * bx
// atan2 gives signed angle; we report the unsigned magnitude because
// "angle between two rays" is orientation-agnostic.
const rad = Math.atan2(Math.abs(cross), dot)
return (rad * 180) / Math.PI
}
// Width = mean of TL→TR and BL→BR (the two "horizontal" sides under the
// stored ordering). Height = mean of TL→BL and TR→BR. This averages out
// minor non-rectangular skew the user may introduce while reshaping.
function rectDimensionsMm(m: RectMeasurement): { widthMm: number; heightMm: number } {
const [tl, tr, br, bl] = m.corners
const wTop = Math.hypot(tr.x - tl.x, tr.y - tl.y)
const wBot = Math.hypot(br.x - bl.x, br.y - bl.y)
const hLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y)
const hRight = Math.hypot(br.x - tr.x, br.y - tr.y)
return {
widthMm: (wTop + wBot) / 2 / props.scalePxPerMm,
heightMm: (hLeft + hRight) / 2 / props.scalePxPerMm,
}
}
// Shoelace area of the quadrilateral, sign-stripped — handles skewed/
// reshaped rectangles correctly. Crossed quads will give a smaller area
// (common shoelace behaviour); we accept that since we don't auto-reorder.
function rectAreaMm2(m: RectMeasurement): number {
const [p0, p1, p2, p3] = m.corners
const cross =
p0.x * p1.y - p1.x * p0.y +
p1.x * p2.y - p2.x * p1.y +
p2.x * p3.y - p3.x * p2.y +
p3.x * p0.y - p0.x * p3.y
const areaPx2 = Math.abs(cross) / 2
return areaPx2 / (props.scalePxPerMm * props.scalePxPerMm)
}
function formatMm(v: number): string {
return v >= 10 ? v.toFixed(1) : v.toFixed(2)
}
function formatArea(v: number): string {
if (v >= 1000) return v.toFixed(0)
if (v >= 100) return v.toFixed(1)
return v.toFixed(2)
}
// Spec for rectangle area readout: 0 decimals when ≥ 100 mm², else 1.
function formatRectArea(v: number): string {
if (v >= 100) return v.toFixed(0)
return v.toFixed(1)
}
function measurementLabel(m: Measurement): string {
if (m.type === "line") {
return `${formatMm(lineLengthMm(m))} mm`
}
if (m.type === "rectangle") {
const { widthMm, heightMm } = rectDimensionsMm(m)
const area = rectAreaMm2(m)
return `${widthMm.toFixed(1)} × ${heightMm.toFixed(1)} mm · ${formatRectArea(area)} mm²`
}
if (m.type === "ellipse") {
const { semiMajor, semiMinor } = ellipseAxesMm(m)
const area = Math.PI * semiMajor * semiMinor
return `${formatMm(semiMajor)}×${formatMm(semiMinor)} mm · ${formatArea(area)} mm²`
}
if (m.type === "circle") {
const r = circleRadiusMm(m)
const diameter = 2 * r
const area = Math.PI * r * r
return `${diameter.toFixed(1)} mm · ${formatRectArea(area)} mm²`
}
return `${angleDegrees(m).toFixed(1)}°`
}
function measurementTypeLabel(m: Measurement): string {
if (m.type === "line") return "Line"
if (m.type === "rectangle") return "Rect"
if (m.type === "ellipse") return "Ellipse"
if (m.type === "circle") return "Circle"
return "Angle"
}
// Side-panel summary uses the shorter "w×h mm" without the area suffix per
// the spec — a separate format from the on-canvas label.
function measurementSummaryValue(m: Measurement): string {
if (m.type === "rectangle") {
const { widthMm, heightMm } = rectDimensionsMm(m)
return `${widthMm.toFixed(1)}×${heightMm.toFixed(1)} mm`
}
if (m.type === "circle") {
const diameter = 2 * circleRadiusMm(m)
return `${diameter.toFixed(1)} mm`
}
return measurementLabel(m)
}
// Anchor point in image space where we place the label. Chosen per type so
// the label sits in a predictable, non-occluding position.
function labelAnchor(m: Measurement): Point {
if (m.type === "line") {
return { x: (m.a.x + m.b.x) / 2, y: (m.a.y + m.b.y) / 2 }
}
if (m.type === "rectangle") {
const [p0, p1, p2, p3] = m.corners
return {
x: (p0.x + p1.x + p2.x + p3.x) / 4,
y: (p0.y + p1.y + p2.y + p3.y) / 4,
}
}
if (m.type === "ellipse") {
return m.center
}
if (m.type === "circle") {
return m.center
}
return m.vertex
}
// Label rectangle in canvas space. Width depends on text so we measure with
// the same ctx we are about to render with. Sizes scale with strokeMul so
// labels stay legible when exporting at a higher pixel resolution.
function labelRect(
ctx: CanvasRenderingContext2D,
m: Measurement,
rt: RenderCtx,
): { x: number; y: number; w: number; h: number; textX: number; textY: number; fontPx: number } {
const anchor = imgToCtx(labelAnchor(m), rt)
const text = measurementLabel(m)
const fontPx = 13 * rt.strokeMul
ctx.save()
ctx.font = `bold ${String(fontPx)}px monospace`
const tw = ctx.measureText(text).width
ctx.restore()
const pad = 6 * rt.strokeMul
const h = 20 * rt.strokeMul
const w = tw + pad * 2
// Offset the label above the anchor so it doesn't sit on top of a handle.
// Anchor types whose anchor coincides with a handle disk need extra
// clearance so the label doesn't sit on top of the handle. Selected
// primary handles are 8 px radius + 2 px stroke, so we need at least
// h/2 + 10 + breathing-room. Line midpoint and rect centroid don't
// host a handle — they only need a small visual gap from the anchor.
const anchorOnHandle =
m.type === "ellipse" ||
m.type === "circle" ||
m.type === "angle"
const offsetY = (anchorOnHandle ? 26 : 16) * rt.strokeMul
const x = anchor.x - w / 2
const y = anchor.y - h / 2 - offsetY
return { x, y, w, h, textX: anchor.x, textY: anchor.y - offsetY, fontPx }
}
function drawMeasurement(
ctx: CanvasRenderingContext2D,
m: Measurement,
isSelected: boolean,
rt: RenderCtx,
) {
const baseColor = getDatumColor(m.colorIndex)
const decorate = rt.drawSelectionDecorations
// The selected shape always renders in its own colour rather than a
// white "highlight" stroke — the colour is what ties the geometry to
// its label pill, so swapping it out on selection actively hurts
// identification. We dim unselected shapes more aggressively (0.45 vs
// 0.8) and keep the thicker, solid stroke for the selected one so it
// still stands out without recolouring.
const strokeColor = baseColor
const lineAlpha = decorate ? (isSelected ? 1.0 : 0.45) : 1.0
const lineWidth = (decorate && isSelected ? 3 : 2) * rt.strokeMul
ctx.save()
ctx.globalAlpha = lineAlpha
if (m.type === "line") {
drawLineGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected, rt)
} else if (m.type === "rectangle") {
drawRectGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected, rt)
} else if (m.type === "ellipse") {
drawEllipseGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected, rt)
} else if (m.type === "circle") {
drawCircleGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected, rt)
} else {
drawAngleGeometry(ctx, m, strokeColor, baseColor, lineWidth, isSelected, rt)
}
ctx.globalAlpha = 1.0
ctx.restore()
}
// Selected items in the live view + everything in export mode draw solid;
// unselected items in the live view get a dashed stroke to fade them back.
function applyDash(ctx: CanvasRenderingContext2D, isSelected: boolean, rt: RenderCtx) {
if (rt.drawSelectionDecorations && !isSelected) {
ctx.setLineDash([6 * rt.strokeMul, 3 * rt.strokeMul])
} else {
ctx.setLineDash([])
}
}
function drawLineGeometry(
ctx: CanvasRenderingContext2D,
m: LineMeasurement,
strokeColor: string,
handleColor: string,
lineWidth: number,
isSelected: boolean,
rt: RenderCtx,
) {
const sa = imgToCtx(m.a, rt)
const sb = imgToCtx(m.b, rt)
ctx.beginPath()
ctx.moveTo(sa.x, sa.y)
ctx.lineTo(sb.x, sb.y)
ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth
applyDash(ctx, isSelected, rt)
ctx.stroke()
ctx.setLineDash([])
if (rt.drawHandles) {
drawHandle(ctx, sa, handleColor, isSelected, false, rt)
drawHandle(ctx, sb, handleColor, isSelected, false, rt)
}
}
function drawRectGeometry(
ctx: CanvasRenderingContext2D,
m: RectMeasurement,
strokeColor: string,
handleColor: string,
lineWidth: number,
isSelected: boolean,
rt: RenderCtx,
) {
const screenCorners = m.corners.map((p) => imgToCtx(p, rt))
ctx.beginPath()
for (let i = 0; i < screenCorners.length; i++) {
const p = screenCorners[i]
if (!p) continue
if (i === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
}
ctx.closePath()
ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth
applyDash(ctx, isSelected, rt)
ctx.stroke()
ctx.setLineDash([])
// Don't fill the interior — keeps what's underneath visible, matching
// the line/ellipse/angle visual style.
if (rt.drawHandles) {
for (const p of screenCorners) {
drawHandle(ctx, p, handleColor, isSelected, false, rt)
}
}
}
function drawEllipseGeometry(
ctx: CanvasRenderingContext2D,
m: EllipseMeasurement,
strokeColor: string,
handleColor: string,
lineWidth: number,
isSelected: boolean,
rt: RenderCtx,
) {
// Parametric draw using the two conjugate axis vectors; handles the
// general (non-perpendicular) case the datum editor also uses.
const c = imgToCtx(m.center, rt)
const a = imgToCtx(m.axisEndA, rt)
const b = imgToCtx(m.axisEndB, rt)
const vAx = a.x - c.x
const vAy = a.y - c.y
const vBx = b.x - c.x
const vBy = b.y - c.y
ctx.beginPath()
const N = 72
for (let i = 0; i <= N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
const x = c.x + vAx * cs + vBx * sn
const y = c.y + vAy * cs + vBy * sn
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth
applyDash(ctx, isSelected, rt)
ctx.stroke()
ctx.setLineDash([])
ctx.save()
ctx.globalAlpha *= 0.5
ctx.beginPath()
ctx.moveTo(c.x, c.y)
ctx.lineTo(a.x, a.y)
ctx.moveTo(c.x, c.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle = strokeColor
ctx.lineWidth = 1 * rt.strokeMul
ctx.stroke()
ctx.restore()
if (rt.drawHandles) {
drawHandle(ctx, c, handleColor, isSelected, true, rt)
drawHandle(ctx, a, handleColor, isSelected, false, rt)
drawHandle(ctx, b, handleColor, isSelected, false, rt)
}
}
function drawCircleGeometry(
ctx: CanvasRenderingContext2D,
m: CircleMeasurement,
strokeColor: string,
handleColor: string,
lineWidth: number,
isSelected: boolean,
rt: RenderCtx,
) {
// True circle: radius is the canvas-space distance from center to edge
// under the active RenderCtx affine. Drawing in canvas space (rather
// than image space + ctx.scale) keeps the stroke width consistent at
// any zoom level, matching the line/ellipse style.
const c = imgToCtx(m.center, rt)
const e = imgToCtx(m.edge, rt)
const r = Math.hypot(e.x - c.x, e.y - c.y)
ctx.beginPath()
ctx.arc(c.x, c.y, r, 0, Math.PI * 2)
ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth
applyDash(ctx, isSelected, rt)
ctx.stroke()
ctx.setLineDash([])
// Faint radius hint, mirroring the ellipse's axis hint lines.
ctx.save()
ctx.globalAlpha *= 0.5
ctx.beginPath()
ctx.moveTo(c.x, c.y)
ctx.lineTo(e.x, e.y)
ctx.strokeStyle = strokeColor
ctx.lineWidth = 1 * rt.strokeMul
ctx.stroke()
ctx.restore()
if (rt.drawHandles) {
drawHandle(ctx, c, handleColor, isSelected, true, rt)
drawHandle(ctx, e, handleColor, isSelected, false, rt)
}
}
function drawAngleGeometry(
ctx: CanvasRenderingContext2D,
m: AngleMeasurement,
strokeColor: string,
handleColor: string,
lineWidth: number,
isSelected: boolean,
rt: RenderCtx,
) {
const v = imgToCtx(m.vertex, rt)
const a = imgToCtx(m.armA, rt)
const b = imgToCtx(m.armB, rt)
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(v.x, v.y)
ctx.lineTo(b.x, b.y)
ctx.strokeStyle = strokeColor
ctx.lineWidth = lineWidth
applyDash(ctx, isSelected, rt)
ctx.stroke()
ctx.setLineDash([])
const lenA = Math.hypot(a.x - v.x, a.y - v.y)
const lenB = Math.hypot(b.x - v.x, b.y - v.y)
const arcR = Math.max(16 * rt.strokeMul, Math.min(lenA, lenB) * 0.3)
if (lenA > 2 && lenB > 2) {
const thetaA = Math.atan2(a.y - v.y, a.x - v.x)
const thetaB = Math.atan2(b.y - v.y, b.x - v.x)
// Always sweep the short way around so the arc visualises the angle
// the number reports (0180°).
let delta = thetaB - thetaA
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
ctx.save()
ctx.globalAlpha *= 0.6
ctx.beginPath()
ctx.arc(v.x, v.y, arcR, thetaA, thetaA + delta, delta < 0)
ctx.strokeStyle = strokeColor
ctx.lineWidth = 1.5 * rt.strokeMul
ctx.stroke()
ctx.restore()
}
if (rt.drawHandles) {
drawHandle(ctx, v, handleColor, isSelected, true, rt)
drawHandle(ctx, a, handleColor, isSelected, false, rt)
drawHandle(ctx, b, handleColor, isSelected, false, rt)
}
}
// Handle rendering follows the datum-editor precedent: a filled color center
// ringed in white. Size and alpha depend on the measurement's selection
// state so the user always sees where to grab, but unselected handles stay
// visually quiet.
// unselected: 3 px radius @ 0.5 alpha
// selected primary (center/vertex): 8 px radius, full alpha, thicker ring
// selected secondary: 6.5 px radius, full alpha
// The invisible hit region (HANDLE_HIT_PX) is wider than any of these so
// grabbing is forgiving even on tiny unselected dots.
function drawHandle(
ctx: CanvasRenderingContext2D,
s: Point,
color: string,
isSelected: boolean,
primary: boolean,
rt: RenderCtx,
) {
ctx.save()
if (isSelected && rt.drawSelectionDecorations) {
const r = (primary ? 8 : 6.5) * rt.strokeMul
ctx.beginPath()
ctx.arc(s.x, s.y, r, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 2 * rt.strokeMul
ctx.stroke()
} else {
if (rt.drawSelectionDecorations) ctx.globalAlpha = 0.5
ctx.beginPath()
ctx.arc(s.x, s.y, 3 * rt.strokeMul, 0, Math.PI * 2)
ctx.fillStyle = color
ctx.fill()
ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 1 * rt.strokeMul
ctx.stroke()
}
ctx.restore()
}
// Resolved label position for one measurement after collision avoidance.
// `pos` is the rect to draw at, `anchor` is the geometry-space anchor in
// canvas coords (used to draw a leader line back when the label is shifted),
// and `shifted` flags whether collision-resolution moved the label far
// enough to warrant a leader line.
interface LabelPos {
x: number
y: number
w: number
h: number
textX: number
textY: number
fontPx: number
anchor: Point
shifted: boolean
}
// AABB overlap with optional gap padding so labels don't sit flush against
// each other.
function rectsOverlap(
a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number },
gap: number,
): boolean {
return !(
a.x + a.w + gap <= b.x ||
b.x + b.w + gap <= a.x ||
a.y + a.h + gap <= b.y ||
b.y + b.h + gap <= a.y
)
}
// Greedy collision avoidance: process labels top-to-bottom by anchor Y,
// place each at its desired position, and if it overlaps any already-placed
// label, push it down past the offender. Predictable, fast for typical
// measurement counts, and keeps every label still horizontally aligned with
// its anchor (we only shift in Y). Labels that move significantly get a
// leader line back to their anchor in `drawLabelAt`.
function resolveLabelPositions(
ctx: CanvasRenderingContext2D,
list: Measurement[],
rt: RenderCtx,
): Map<string, LabelPos> {
const gap = 8 * rt.strokeMul
const items = list.map((m) => {
const anchor = imgToCtx(labelAnchor(m), rt)
const rect = labelRect(ctx, m, rt)
return { id: m.id, anchor, rect, originalY: rect.y }
})
items.sort((a, b) => a.anchor.y - b.anchor.y)
const placed: typeof items = []
for (const it of items) {
let safety = 50
while (safety-- > 0) {
let collided = false
for (const p of placed) {
if (rectsOverlap(it.rect, p.rect, gap)) {
const shift = p.rect.y + p.rect.h + gap - it.rect.y
it.rect = {
...it.rect,
y: it.rect.y + shift,
textY: it.rect.textY + shift,
}
collided = true
break
}
}
if (!collided) break
}
placed.push(it)
}
// Clamp every label to the destination canvas so exports never clip
// labels off the edge. Done after collision resolution as a separate
// pass — clamping can re-introduce overlaps, which we accept since
// staying inside the bitmap is more important than zero-overlap on
// crowded edges. The live overlay benefits too: labels near the
// viewport edge stay visible instead of running off the canvas.
const cw = ctx.canvas.width
const ch = ctx.canvas.height
const out = new Map<string, LabelPos>()
const shiftThreshold = 4 * rt.strokeMul
for (const it of placed) {
let { x, y, w, h, textX, textY, fontPx } = it.rect
const dx =
x < 0 ? -x : x + w > cw ? cw - (x + w) : 0
const dy =
y < 0 ? -y : y + h > ch ? ch - (y + h) : 0
x += dx
y += dy
textX += dx
textY += dy
out.set(it.id, {
x,
y,
w,
h,
textX,
textY,
fontPx,
anchor: it.anchor,
shifted: Math.abs(y - it.originalY) > shiftThreshold,
})
}
return out
}
function drawLabelAt(
ctx: CanvasRenderingContext2D,
m: Measurement,
isSelected: boolean,
rt: RenderCtx,
pos: LabelPos,
) {
const baseColor = getDatumColor(m.colorIndex)
const decorate = rt.drawSelectionDecorations
const labelAlpha = decorate ? (isSelected ? 1.0 : 0.7) : 1.0
// Leader line back to the geometry anchor when collision resolution
// dragged the label away from its preferred position. Drawn first so
// the label box paints over the line where they meet.
if (pos.shifted) {
ctx.save()
ctx.globalAlpha = labelAlpha * 0.6
ctx.strokeStyle = baseColor
ctx.lineWidth = 1 * rt.strokeMul
ctx.setLineDash([3 * rt.strokeMul, 2 * rt.strokeMul])
ctx.beginPath()
ctx.moveTo(pos.anchor.x, pos.anchor.y)
ctx.lineTo(pos.x + pos.w / 2, pos.y + pos.h / 2)
ctx.stroke()
ctx.setLineDash([])
ctx.restore()
}
ctx.save()
ctx.globalAlpha = labelAlpha
// In export mode every label uses the measurement's own colour for the
// pill — it's the only signal that ties a label to its geometry without
// the live highlight.
ctx.fillStyle = decorate
? isSelected
? baseColor
: "rgba(0, 0, 0, 0.75)"
: baseColor
roundRect(ctx, pos.x, pos.y, pos.w, pos.h, 4 * rt.strokeMul)
ctx.fill()
// Colored border on the dark unselected pill ties a label to its
// geometry without relying on selection state — without this, every
// unselected label looked identical. Selected labels use a white border
// for the highlight ring; in export-mode pills are filled with baseColor
// and don't need a border.
if (decorate) {
if (isSelected) {
ctx.strokeStyle = "#ffffff"
ctx.lineWidth = 1 * rt.strokeMul
} else {
ctx.strokeStyle = baseColor
ctx.lineWidth = 1.5 * rt.strokeMul
}
ctx.stroke()
}
ctx.font = `bold ${String(pos.fontPx)}px monospace`
ctx.fillStyle = "#ffffff"
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(measurementLabel(m), pos.textX, pos.textY)
ctx.textAlign = "start"
ctx.textBaseline = "alphabetic"
ctx.restore()
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath()
ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - rr, y)
ctx.arcTo(x + w, y, x + w, y + rr, rr)
ctx.lineTo(x + w, y + h - rr)
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr)
ctx.lineTo(x + rr, y + h)
ctx.arcTo(x, y + h, x, y + h - rr, rr)
ctx.lineTo(x, y + rr)
ctx.arcTo(x, y, x + rr, y, rr)
ctx.closePath()
}
function drawPlacementPreview(ctx: CanvasRenderingContext2D) {
const color = getDatumColor(colorCounter)
const pts = placementPoints.value
const cursor = placementCursor.value
ctx.save()
ctx.globalAlpha = 0.9
ctx.strokeStyle = color
ctx.fillStyle = color
ctx.lineWidth = 2
ctx.setLineDash([4, 3])
// Snap the cursor for tools whose direction is meaningful: line snaps
// relative to the first endpoint, angle relative to the vertex (pts[0]).
// Rect and ellipse don't snap — rect is axis-aligned by construction
// and the ellipse's "direction" is just an axis label, not orientation
// the user typically cares to lock to 45°.
let effectiveCursor = cursor
if (cursor && pts.length >= 1 && pts[0]) {
if (activeTool.value === "line" || activeTool.value === "angle") {
effectiveCursor = maybeSnap45(pts[0], cursor)
}
}
const sPts = pts.map(imgToScreen)
const sCursor = effectiveCursor ? imgToScreen(effectiveCursor) : null
if (activeTool.value === "line" && sPts.length >= 1 && sPts[0] && sCursor) {
ctx.beginPath()
ctx.moveTo(sPts[0].x, sPts[0].y)
ctx.lineTo(sCursor.x, sCursor.y)
ctx.stroke()
} else if (activeTool.value === "rectangle" && sPts.length >= 1 && sPts[0] && sCursor) {
const a = sPts[0]
const b = sCursor
ctx.beginPath()
ctx.rect(a.x, a.y, b.x - a.x, b.y - a.y)
ctx.stroke()
} else if (activeTool.value === "ellipse" && sPts.length >= 1 && sPts[0]) {
const center = sPts[0]
const endA = sPts[1] ?? sCursor
if (endA) {
ctx.beginPath()
ctx.moveTo(center.x, center.y)
ctx.lineTo(endA.x, endA.y)
ctx.stroke()
}
if (sPts.length >= 2 && sPts[1] && sCursor) {
const a = sPts[1]
const b = sCursor
ctx.beginPath()
ctx.moveTo(center.x, center.y)
ctx.lineTo(b.x, b.y)
ctx.stroke()
const vAx = a.x - center.x
const vAy = a.y - center.y
const vBx = b.x - center.x
const vBy = b.y - center.y
ctx.beginPath()
const N = 72
for (let i = 0; i <= N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
const x = center.x + vAx * cs + vBx * sn
const y = center.y + vAy * cs + vBy * sn
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
} else if (activeTool.value === "circle" && sPts.length >= 1 && sPts[0] && sCursor) {
const center = sPts[0]
const r = Math.hypot(sCursor.x - center.x, sCursor.y - center.y)
ctx.beginPath()
ctx.moveTo(center.x, center.y)
ctx.lineTo(sCursor.x, sCursor.y)
ctx.stroke()
ctx.beginPath()
ctx.arc(center.x, center.y, r, 0, Math.PI * 2)
ctx.stroke()
} else if (activeTool.value === "angle" && sPts.length >= 1 && sPts[0]) {
const v = sPts[0]
const a = sPts[1] ?? sCursor
if (a) {
ctx.beginPath()
ctx.moveTo(a.x, a.y)
ctx.lineTo(v.x, v.y)
ctx.stroke()
}
if (sPts.length >= 2 && sPts[1] && sCursor) {
ctx.beginPath()
ctx.moveTo(v.x, v.y)
ctx.lineTo(sCursor.x, sCursor.y)
ctx.stroke()
}
}
ctx.setLineDash([])
for (const sp of sPts) {
ctx.beginPath()
ctx.arc(sp.x, sp.y, 5, 0, Math.PI * 2)
ctx.fill()
}
ctx.restore()
}
function getCanvasXY(e: MouseEvent | Touch): { x: number; y: number } {
const rect = overlayRef.value?.getBoundingClientRect()
if (!rect) return { x: 0, y: 0 }
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
// Hit-testing helpers. All thresholds are in screen pixels so they feel
// consistent to the user regardless of zoom level.
// Generous invisible hotspot around each handle so precision grabs feel
// forgiving on small unselected dots. Larger than any rendered handle.
const HANDLE_HIT_PX = 14
const LINE_HIT_PX = 6
const ELLIPSE_HIT_PX = 7
const CIRCLE_HIT_PX = 6
function pointToSegmentDistance(
p: Point,
a: Point,
b: Point,
): number {
const abx = b.x - a.x
const aby = b.y - a.y
const len2 = abx * abx + aby * aby
if (len2 === 0) return Math.hypot(p.x - a.x, p.y - a.y)
let t = ((p.x - a.x) * abx + (p.y - a.y) * aby) / len2
t = Math.max(0, Math.min(1, t))
const qx = a.x + t * abx
const qy = a.y + t * aby
return Math.hypot(p.x - qx, p.y - qy)
}
// Standard ray-cast point-in-polygon. Works for any simple quadrilateral
// (including reshaped non-rect cases) and gracefully degrades to a small
// or zero region for crossed quads — which is what we want, since a
// "crossed" rectangle is effectively user error.
function pointInPolygon(p: Point, poly: Point[]): boolean {
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i, i++) {
const pi = poly[i]
const pj = poly[j]
if (!pi || !pj) continue
const intersect =
pi.y > p.y !== pj.y > p.y &&
p.x < ((pj.x - pi.x) * (p.y - pi.y)) / (pj.y - pi.y) + pi.x
if (intersect) inside = !inside
}
return inside
}
// Returns the min screen-space distance from cursor to the ellipse curve.
// Sampled parametrically; 96 samples is overkill for hit-testing but cheap.
function ellipseCurveDistance(
cursor: Point,
m: EllipseMeasurement,
): number {
const c = imgToScreen(m.center)
const a = imgToScreen(m.axisEndA)
const b = imgToScreen(m.axisEndB)
const vAx = a.x - c.x
const vAy = a.y - c.y
const vBx = b.x - c.x
const vBy = b.y - c.y
let best = Infinity
const N = 96
for (let i = 0; i < N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
const x = c.x + vAx * cs + vBx * sn
const y = c.y + vAy * cs + vBy * sn
const d = Math.hypot(cursor.x - x, cursor.y - y)
if (d < best) best = d
}
return best
}
interface HitResult {
measurementId: string
// "handle" means the user grabbed a specific control point.
// "geometry" means they grabbed the line/curve/arms — whole-measurement drag.
// "label" means they clicked the label — selection only (drag moves whole).
kind: "handle" | "geometry" | "label"
handleKey: string | null
}
function getHandlePositions(m: Measurement): { key: string; pt: Point }[] {
if (m.type === "line") {
return [
{ key: "a", pt: m.a },
{ key: "b", pt: m.b },
]
}
if (m.type === "rectangle") {
return [
{ key: "c0", pt: m.corners[0] },
{ key: "c1", pt: m.corners[1] },
{ key: "c2", pt: m.corners[2] },
{ key: "c3", pt: m.corners[3] },
]
}
if (m.type === "ellipse") {
return [
{ key: "center", pt: m.center },
{ key: "axisEndA", pt: m.axisEndA },
{ key: "axisEndB", pt: m.axisEndB },
]
}
if (m.type === "circle") {
return [
{ key: "center", pt: m.center },
{ key: "edge", pt: m.edge },
]
}
return [
{ key: "vertex", pt: m.vertex },
{ key: "armA", pt: m.armA },
{ key: "armB", pt: m.armB },
]
}
function hitTest(cursorScreen: Point): HitResult | null {
const ctx = overlayRef.value?.getContext("2d")
if (!ctx) return null
// Check the selected measurement first — its label is visually on top,
// so its hit region should win ties.
const ordered: Measurement[] = []
const sel = measurements.value.find((m) => m.id === selectedId.value)
if (sel) ordered.push(sel)
for (const m of measurements.value) {
if (m.id !== selectedId.value) ordered.push(m)
}
// Priority 1: handles (selected first, so you can always grab the active
// measurement's handle even if it overlaps another). Handles beat geometry
// so precision grabs on endpoints always win over a line-body grab.
for (const m of ordered) {
for (const h of getHandlePositions(m)) {
const s = imgToScreen(h.pt)
if (Math.hypot(cursorScreen.x - s.x, cursorScreen.y - s.y) <= HANDLE_HIT_PX) {
return { measurementId: m.id, kind: "handle", handleKey: h.key }
}
}
}
// Priority 2: labels.
const liveRt = makeLiveCtx()
for (const m of ordered) {
const rect = labelRect(ctx, m, liveRt)
if (
cursorScreen.x >= rect.x &&
cursorScreen.x <= rect.x + rect.w &&
cursorScreen.y >= rect.y &&
cursorScreen.y <= rect.y + rect.h
) {
return { measurementId: m.id, kind: "label", handleKey: null }
}
}
// Priority 3: geometry bodies.
for (const m of ordered) {
if (m.type === "line") {
const sa = imgToScreen(m.a)
const sb = imgToScreen(m.b)
if (pointToSegmentDistance(cursorScreen, sa, sb) <= LINE_HIT_PX) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
} else if (m.type === "rectangle") {
const sc = m.corners.map(imgToScreen)
// Edge-near test for thin grabs along the border.
let edgeHit = false
for (let i = 0; i < 4; i++) {
const a = sc[i]
const b = sc[(i + 1) % 4]
if (a && b && pointToSegmentDistance(cursorScreen, a, b) <= LINE_HIT_PX) {
edgeHit = true
break
}
}
// Interior-fill test so a big rect is grabbable from anywhere
// inside, not just along the 6px edge band.
if (edgeHit || pointInPolygon(cursorScreen, sc)) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
} else if (m.type === "ellipse") {
if (ellipseCurveDistance(cursorScreen, m) <= ELLIPSE_HIT_PX) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
} else if (m.type === "circle") {
// Edge: near the circumference. Interior: anywhere inside the
// circle, so big circles are easy to grab without precision.
const c = imgToScreen(m.center)
const e = imgToScreen(m.edge)
const radius = Math.hypot(e.x - c.x, e.y - c.y)
const distToCenter = Math.hypot(cursorScreen.x - c.x, cursorScreen.y - c.y)
if (
Math.abs(distToCenter - radius) <= CIRCLE_HIT_PX ||
distToCenter <= radius
) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
} else {
const v = imgToScreen(m.vertex)
const a = imgToScreen(m.armA)
const b = imgToScreen(m.armB)
if (
pointToSegmentDistance(cursorScreen, v, a) <= LINE_HIT_PX ||
pointToSegmentDistance(cursorScreen, v, b) <= LINE_HIT_PX
) {
return { measurementId: m.id, kind: "geometry", handleKey: null }
}
}
}
return null
}
function commitPlacement() {
const pts = placementPoints.value
if (activeTool.value === "line" && pts.length === 2) {
const [a, b] = pts as [Point, Point]
const id = nanoid()
measurements.value.push({
id,
type: "line",
colorIndex: colorCounter,
a,
b,
})
colorCounter += 1
selectedId.value = id
placementPoints.value = []
} else if (activeTool.value === "rectangle" && pts.length === 2) {
const [p1, p2] = pts as [Point, Point]
// Normalise so corners are TL/TR/BR/BL regardless of click order.
const minX = Math.min(p1.x, p2.x)
const maxX = Math.max(p1.x, p2.x)
const minY = Math.min(p1.y, p2.y)
const maxY = Math.max(p1.y, p2.y)
const id = nanoid()
measurements.value.push({
id,
type: "rectangle",
colorIndex: colorCounter,
corners: [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
],
})
colorCounter += 1
selectedId.value = id
placementPoints.value = []
} else if (activeTool.value === "ellipse" && pts.length === 3) {
const [center, axisEndA, axisEndB] = pts as [Point, Point, Point]
const id = nanoid()
measurements.value.push({
id,
type: "ellipse",
colorIndex: colorCounter,
center,
axisEndA,
axisEndB,
})
colorCounter += 1
selectedId.value = id
placementPoints.value = []
} else if (activeTool.value === "circle" && pts.length === 2) {
const [center, edge] = pts as [Point, Point]
const id = nanoid()
measurements.value.push({
id,
type: "circle",
colorIndex: colorCounter,
center,
edge,
})
colorCounter += 1
selectedId.value = id
placementPoints.value = []
} else if (activeTool.value === "angle" && pts.length === 3) {
const [vertex, armA, armB] = pts as [Point, Point, Point]
const id = nanoid()
measurements.value.push({
id,
type: "angle",
colorIndex: colorCounter,
vertex,
armA,
armB,
})
colorCounter += 1
selectedId.value = id
placementPoints.value = []
}
}
function handlePlacementClick(imgPt: Point) {
if (activeTool.value === "none") return
// Snap the click before storing so the preview, the committed
// measurement, and any subsequent point all see the same coordinate.
// Mirrors `drawPlacementPreview`'s snap rules.
const pts = placementPoints.value
let pt = imgPt
if (pts.length >= 1 && pts[0]) {
if (activeTool.value === "line" || activeTool.value === "angle") {
pt = maybeSnap45(pts[0], imgPt)
}
}
placementPoints.value.push(pt)
const needed =
activeTool.value === "line" ||
activeTool.value === "rectangle" ||
activeTool.value === "circle"
? 2
: 3
if (placementPoints.value.length >= needed) {
commitPlacement()
}
}
function cancelPlacement() {
placementPoints.value = []
drawOverlay()
}
function setTool(tool: ToolMode) {
if (activeTool.value === tool) {
activeTool.value = "none"
placementPoints.value = []
} else {
activeTool.value = tool
placementPoints.value = []
selectedId.value = null
}
drawOverlay()
}
function deleteMeasurement(id: string) {
measurements.value = measurements.value.filter((m) => m.id !== id)
if (selectedId.value === id) selectedId.value = null
drawOverlay()
}
function selectMeasurement(id: string | null) {
selectedId.value = id
drawOverlay()
}
function clearMeasurements() {
measurements.value = []
selectedId.value = null
placementPoints.value = []
drawOverlay()
}
// Clones a measurement so we can capture a drag-start snapshot. Plain spread
// wouldn't deep-copy the nested Point objects, which we mutate per frame.
function cloneMeasurement(m: Measurement): Measurement {
if (m.type === "line") {
return { ...m, a: { ...m.a }, b: { ...m.b } }
}
if (m.type === "rectangle") {
return {
...m,
corners: [
{ ...m.corners[0] },
{ ...m.corners[1] },
{ ...m.corners[2] },
{ ...m.corners[3] },
],
}
}
if (m.type === "ellipse") {
return {
...m,
center: { ...m.center },
axisEndA: { ...m.axisEndA },
axisEndB: { ...m.axisEndB },
}
}
if (m.type === "circle") {
return {
...m,
center: { ...m.center },
edge: { ...m.edge },
}
}
return {
...m,
vertex: { ...m.vertex },
armA: { ...m.armA },
armB: { ...m.armB },
}
}
function applyDrag(
original: Measurement,
mode: DragMode,
handleKey: string | null,
dx: number,
dy: number,
): Measurement {
if (mode === "move") {
if (original.type === "line") {
return {
...original,
a: { x: original.a.x + dx, y: original.a.y + dy },
b: { x: original.b.x + dx, y: original.b.y + dy },
}
}
if (original.type === "rectangle") {
return {
...original,
corners: [
{ x: original.corners[0].x + dx, y: original.corners[0].y + dy },
{ x: original.corners[1].x + dx, y: original.corners[1].y + dy },
{ x: original.corners[2].x + dx, y: original.corners[2].y + dy },
{ x: original.corners[3].x + dx, y: original.corners[3].y + dy },
],
}
}
if (original.type === "ellipse") {
return {
...original,
center: { x: original.center.x + dx, y: original.center.y + dy },
axisEndA: { x: original.axisEndA.x + dx, y: original.axisEndA.y + dy },
axisEndB: { x: original.axisEndB.x + dx, y: original.axisEndB.y + dy },
}
}
if (original.type === "circle") {
return {
...original,
center: { x: original.center.x + dx, y: original.center.y + dy },
edge: { x: original.edge.x + dx, y: original.edge.y + dy },
}
}
return {
...original,
vertex: { x: original.vertex.x + dx, y: original.vertex.y + dy },
armA: { x: original.armA.x + dx, y: original.armA.y + dy },
armB: { x: original.armB.x + dx, y: original.armB.y + dy },
}
}
if (mode === "handle" && handleKey) {
if (original.type === "line") {
if (handleKey === "a") {
const raw = { x: original.a.x + dx, y: original.a.y + dy }
return { ...original, a: maybeSnap45(original.b, raw) }
}
if (handleKey === "b") {
const raw = { x: original.b.x + dx, y: original.b.y + dy }
return { ...original, b: maybeSnap45(original.a, raw) }
}
} else if (original.type === "rectangle") {
// Constrain to an axis-aligned rectangle: the dragged corner
// follows the cursor, the diagonally-opposite corner stays put,
// and the two adjacent corners are recomputed from the cross of
// (dragged.x, opp.y) and (opp.x, dragged.y) so the shape stays
// rectangular. Corner indices stay stable — c0 is still the
// logical TL even if the box flips through itself.
const cornerIdx: 0 | 1 | 2 | 3 | null =
handleKey === "c0" ? 0 :
handleKey === "c1" ? 1 :
handleKey === "c2" ? 2 :
handleKey === "c3" ? 3 : null
if (cornerIdx !== null) {
const moving = {
x: original.corners[cornerIdx].x + dx,
y: original.corners[cornerIdx].y + dy,
}
const oppIdx = ((cornerIdx + 2) % 4) as 0 | 1 | 2 | 3
const opp = { ...original.corners[oppIdx] }
const next: [Point, Point, Point, Point] = [
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
]
next[cornerIdx] = moving
next[oppIdx] = opp
if (cornerIdx === 0 || cornerIdx === 2) {
// TL ↔ BR diagonal: TR=(BR.x, TL.y), BL=(TL.x, BR.y).
const tl = next[0]
const br = next[2]
next[1] = { x: br.x, y: tl.y }
next[3] = { x: tl.x, y: br.y }
} else {
// TR ↔ BL diagonal: TL=(BL.x, TR.y), BR=(TR.x, BL.y).
const tr = next[1]
const bl = next[3]
next[0] = { x: bl.x, y: tr.y }
next[2] = { x: tr.x, y: bl.y }
}
return { ...original, corners: next }
}
} else if (original.type === "ellipse") {
if (handleKey === "center") {
// Dragging the ellipse center translates the whole ellipse so
// the axis endpoints keep their conjugate relationship.
return {
...original,
center: { x: original.center.x + dx, y: original.center.y + dy },
axisEndA: { x: original.axisEndA.x + dx, y: original.axisEndA.y + dy },
axisEndB: { x: original.axisEndB.x + dx, y: original.axisEndB.y + dy },
}
}
if (handleKey === "axisEndA") {
return { ...original, axisEndA: { x: original.axisEndA.x + dx, y: original.axisEndA.y + dy } }
}
if (handleKey === "axisEndB") {
return { ...original, axisEndB: { x: original.axisEndB.x + dx, y: original.axisEndB.y + dy } }
}
} else if (original.type === "circle") {
if (handleKey === "center") {
// Dragging the center translates the whole circle so the
// radius is preserved.
return {
...original,
center: { x: original.center.x + dx, y: original.center.y + dy },
edge: { x: original.edge.x + dx, y: original.edge.y + dy },
}
}
if (handleKey === "edge") {
// Edge follows the cursor; center stays put → radius changes.
return { ...original, edge: { x: original.edge.x + dx, y: original.edge.y + dy } }
}
} else {
if (handleKey === "vertex") {
// Like ellipse center: dragging vertex carries the arms so the
// angle shape is preserved.
return {
...original,
vertex: { x: original.vertex.x + dx, y: original.vertex.y + dy },
armA: { x: original.armA.x + dx, y: original.armA.y + dy },
armB: { x: original.armB.x + dx, y: original.armB.y + dy },
}
}
if (handleKey === "armA") {
const raw = { x: original.armA.x + dx, y: original.armA.y + dy }
return { ...original, armA: maybeSnap45(original.vertex, raw) }
}
if (handleKey === "armB") {
const raw = { x: original.armB.x + dx, y: original.armB.y + dy }
return { ...original, armB: maybeSnap45(original.vertex, raw) }
}
}
}
return original
}
function updateMeasurement(id: string, next: Measurement) {
const idx = measurements.value.findIndex((m) => m.id === id)
if (idx === -1) return
measurements.value[idx] = next
}
// Hit-test-first press handler, mirroring Konva's behaviour in the datum
// editor: a click on an existing shape always wins over the stage's own
// drag/pan, AND over any active placement tool. Returns "measurement" if
// we picked up an existing measurement (caller suppresses the trailing
// click so a placement tool doesn't commit a spurious point), "pan" if
// the press should start a stage pan, and "placement" if the press
// landed on empty space while a placement tool is active (caller does
// nothing — the click event will commit the placement).
function pointerDown(
screenX: number,
screenY: number,
): "measurement" | "pan" | "placement" {
const cursor = { x: screenX, y: screenY }
const hit = hitTest(cursor)
if (hit) {
const target = measurements.value.find((m) => m.id === hit.measurementId)
// Closed shapes (circle, rectangle) are only draggable from their
// handles — corner dots for rect, center / edge for circle. Body
// hits select but never start a drag, so big shapes don't drift
// when the user clicks inside them. When a placement tool is
// active, body hits fall through entirely so the user can draw a
// new measurement on top of an existing closed shape.
const isClosedBodyHit =
(target?.type === "circle" || target?.type === "rectangle") &&
hit.kind !== "handle"
if (isClosedBodyHit && activeTool.value !== "none") {
return "placement"
}
selectedId.value = hit.measurementId
if (target && !isClosedBodyHit) {
const mode: DragMode = hit.kind === "handle" ? "handle" : "move"
dragState = {
mode,
measurementId: target.id,
handleKey: hit.handleKey,
startImg: screenToImg(screenX, screenY),
startSnapshot: cloneMeasurement(target),
}
}
drawOverlay()
return "measurement"
}
// Empty-space press.
if (activeTool.value !== "none") return "placement"
if (selectedId.value !== null) {
selectedId.value = null
drawOverlay()
}
return "pan"
}
function pointerMove(screenX: number, screenY: number): boolean {
if (!dragState) return false
const nowImg = screenToImg(screenX, screenY)
const dxImg = nowImg.x - dragState.startImg.x
const dyImg = nowImg.y - dragState.startImg.y
const next = applyDrag(
dragState.startSnapshot,
dragState.mode,
dragState.handleKey,
dxImg,
dyImg,
)
updateMeasurement(dragState.measurementId, next)
drawOverlay()
return true
}
function pointerUp() {
dragState = null
}
// Zoom anchoring needs the bitmap-space point under the cursor, not the
// measurement-space point. `screenToImg` peels off the crop pre-transform
// to return the latter, which is correct for measurement placement but
// breaks the zoom-anchor math (`offset = screen - bitmap * scale`).
// Without crop the two are equal, which is why the bug only shows up
// once the user crops the deskew result.
function screenToBitmap(sx: number, sy: number): Point {
return {
x: (sx - viewOffsetX.value) / viewScale.value,
y: (sy - viewOffsetY.value) / viewScale.value,
}
}
function onWheel(e: WheelEvent) {
e.preventDefault()
const scaleBy = 1.08
const oldScale = viewScale.value
const newScale = e.deltaY < 0
? oldScale * scaleBy
: oldScale / scaleBy
const clamped = Math.max(0.05, Math.min(20, newScale))
const { x: px, y: py } = getCanvasXY(e)
const bmp = screenToBitmap(px, py)
viewScale.value = clamped
viewOffsetX.value = px - bmp.x * clamped
viewOffsetY.value = py - bmp.y * clamped
redraw()
}
// Once a press starts on the canvas we listen at the window level so the
// drag survives the cursor leaving the canvas (or moving faster than the
// browser's canvas-bound event firing). Re-attached on each press, removed
// when the drag/pan ends.
function attachWindowDragListeners() {
window.addEventListener("mousemove", onWindowMouseMove)
window.addEventListener("mouseup", onWindowMouseUp)
}
function detachWindowDragListeners() {
window.removeEventListener("mousemove", onWindowMouseMove)
window.removeEventListener("mouseup", onWindowMouseUp)
}
// True between an existing-measurement-grab on mousedown and the trailing
// click event the browser will fire. Suppresses the placement-tool click
// so grabbing a prior measurement doesn't commit a spurious new point.
let suppressNextClick = false
function onMouseDown(e: MouseEvent) {
const { x, y } = getCanvasXY(e)
suppressNextClick = false
const outcome = pointerDown(x, y)
if (outcome === "measurement") {
suppressNextClick = true
} else if (outcome === "pan") {
isPanning = true
panStart = { x: e.clientX - viewOffsetX.value, y: e.clientY - viewOffsetY.value }
}
if (dragState || isPanning) attachWindowDragListeners()
}
function onWindowMouseMove(e: MouseEvent) {
if (dragState) {
const { x, y } = getCanvasXY(e)
pointerMove(x, y)
return
}
if (isPanning) {
viewOffsetX.value = e.clientX - panStart.x
viewOffsetY.value = e.clientY - panStart.y
redraw()
}
}
function onWindowMouseUp() {
pointerUp()
isPanning = false
detachWindowDragListeners()
}
function onMouseMove(e: MouseEvent) {
// While a drag/pan is in flight the window listener handles motion;
// here we only need the placement-preview cursor.
if (dragState || isPanning) return
if (activeTool.value !== "none") {
const { x, y } = getCanvasXY(e)
placementCursor.value = screenToImg(x, y)
drawOverlay()
}
}
function onMouseUp() {
// Mouseup that lands inside the canvas — covered by the window listener
// too, but we keep this so a quick click without movement still ends
// cleanly even if for some reason the window handler misses.
if (dragState || isPanning) {
pointerUp()
isPanning = false
detachWindowDragListeners()
}
}
function onMouseLeave() {
// Don't end the drag here — the window listener takes over while the
// cursor is outside the canvas. Just clear the placement preview.
placementCursor.value = null
drawOverlay()
}
function onClick(e: MouseEvent) {
if (suppressNextClick) {
// Press picked up an existing measurement — the trailing click
// is part of that gesture, not a placement.
suppressNextClick = false
return
}
if (activeTool.value === "none") return
const { x, y } = getCanvasXY(e)
const imgPt = screenToImg(x, y)
handlePlacementClick(imgPt)
drawOverlay()
}
function onTouchStart(e: TouchEvent) {
const t0 = e.touches[0]
const t1 = e.touches[1]
if (e.touches.length === 2 && t0 && t1) {
e.preventDefault()
lastPinchDist = Math.hypot(
t1.clientX - t0.clientX,
t1.clientY - t0.clientY,
)
// Cancel any in-progress drag when a second finger lands; pinch takes
// priority.
pointerUp()
isPanning = false
} else if (e.touches.length === 1 && t0) {
const { x, y } = getCanvasXY(t0)
// Always hit-test first so a tap on an existing handle reshapes
// it even when a placement tool is active.
suppressNextClick = false
const outcome = pointerDown(x, y)
if (outcome === "measurement") {
suppressNextClick = true
} else if (outcome === "placement") {
placementCursor.value = screenToImg(x, y)
} else if (outcome === "pan") {
isPanning = true
panStart = {
x: t0.clientX - viewOffsetX.value,
y: t0.clientY - viewOffsetY.value,
}
}
}
}
function onTouchMove(e: TouchEvent) {
const t0 = e.touches[0]
const t1 = e.touches[1]
if (e.touches.length === 2 && t0 && t1) {
e.preventDefault()
const dist = Math.hypot(
t1.clientX - t0.clientX,
t1.clientY - t0.clientY,
)
const factor = dist / lastPinchDist
const oldScale = viewScale.value
const newScale = Math.max(0.05, Math.min(20, oldScale * factor))
const rect = overlayRef.value?.getBoundingClientRect()
if (!rect) return
const cx = (t0.clientX + t1.clientX) / 2 - rect.left
const cy = (t0.clientY + t1.clientY) / 2 - rect.top
const bmp = screenToBitmap(cx, cy)
viewScale.value = newScale
viewOffsetX.value = cx - bmp.x * newScale
viewOffsetY.value = cy - bmp.y * newScale
lastPinchDist = dist
redraw()
} else if (e.touches.length === 1 && t0) {
const { x, y } = getCanvasXY(t0)
if (dragState) {
e.preventDefault()
pointerMove(x, y)
return
}
if (activeTool.value !== "none") {
placementCursor.value = screenToImg(x, y)
drawOverlay()
return
}
if (isPanning) {
viewOffsetX.value = t0.clientX - panStart.x
viewOffsetY.value = t0.clientY - panStart.y
redraw()
}
}
}
function onTouchEnd() {
// Placement on touch relies on the browser-synthesized click that fires
// after a tap with no preventDefault — same as the original file did.
pointerUp()
isPanning = false
lastPinchDist = 0
drawOverlay()
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (activeTool.value !== "none" && placementPoints.value.length > 0) {
cancelPlacement()
return
}
if (activeTool.value !== "none") {
activeTool.value = "none"
drawOverlay()
return
}
if (selectedId.value !== null) {
selectedId.value = null
drawOverlay()
return
}
if (isFullscreen.value) {
isFullscreen.value = false
}
return
}
if ((e.key === "Delete" || e.key === "Backspace") && selectedId.value) {
// Only handle when the overlay is the active context; text inputs
// inside the toolbar need Backspace for editing.
const target = e.target as HTMLElement | null
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")) return
e.preventDefault()
deleteMeasurement(selectedId.value)
}
}
const placementHint = computed<string | null>(() => {
if (activeTool.value === "none") return null
const n = placementPoints.value.length
if (activeTool.value === "line") {
if (n === 0) return "Click the first endpoint."
return "Click the second endpoint."
}
if (activeTool.value === "rectangle") {
if (n === 0) return "Click the first corner."
return "Click the opposite corner."
}
if (activeTool.value === "ellipse") {
if (n === 0) return "Click the ellipse center."
if (n === 1) return "Click the first semi-axis endpoint."
return "Click the second semi-axis endpoint."
}
if (activeTool.value === "circle") {
if (n === 0) return "Click the center."
return "Click a point on the circumference."
}
if (n === 0) return "Click the angle vertex."
if (n === 1) return "Click the first arm endpoint."
return "Click the second arm endpoint."
})
const measurementSummaries = computed(() => {
return measurements.value.map((m) => ({
id: m.id,
type: m.type,
typeLabel: measurementTypeLabel(m),
label: measurementSummaryValue(m),
color: getDatumColor(m.colorIndex),
selected: m.id === selectedId.value,
}))
})
// Scale-bar primitive shared by the legacy `exportWithScaleBar` (image only)
// and the new `exportWithMeasurements` (image + overlay). Returns a fresh
// canvas of width=src.width, height=src.height + barHeight with the source
// blitted on top and the bar drawn into the bottom strip.
// srcCanvas: the bitmap to ride on top of the bar (image, or image +
// overlay in canvas-space).
// pxPerMm: canvas-pixels per real-world millimetre. The bar's mm length
// is picked from this so it represents the same physical span the
// output bitmap does. For full-resolution exports this is the source
// scale; for view exports this is `props.scalePxPerMm * viewScale`
// because the viewport zoom changes how many canvas pixels span a mm.
function appendScaleBarCanvas(
srcCanvas: HTMLCanvasElement,
pxPerMm: number,
): HTMLCanvasElement {
const iw = srcCanvas.width
const ih = srcCanvas.height
const unit = Math.max(iw / 100, 8)
const barHeightPx = Math.round(unit * 5)
const out = document.createElement("canvas")
out.width = iw
out.height = ih + barHeightPx
const ctx = out.getContext("2d")
if (!ctx) return srcCanvas
ctx.drawImage(srcCanvas, 0, 0)
ctx.fillStyle = "#000"
ctx.fillRect(0, ih, iw, barHeightPx)
const imgWidthMm = pxPerMm > 0 ? iw / pxPerMm : 0
const niceSteps = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000]
const targetMm = imgWidthMm * 0.2
let barMm = niceSteps[0] ?? 10
for (const s of niceSteps) {
barMm = s
if (s >= targetMm) break
}
const barWidthPx = barMm * pxPerMm
const margin = Math.round(unit * 2)
const barX = margin
const barY = ih + barHeightPx / 2
const barThick = Math.max(Math.round(unit * 0.6), 4)
const tickH = Math.round(unit * 1.5)
const tickW = Math.max(2, Math.round(unit * 0.15))
ctx.fillStyle = "#fff"
ctx.fillRect(barX, barY - barThick / 2, barWidthPx, barThick)
ctx.fillRect(barX, barY - tickH / 2, tickW, tickH)
ctx.fillRect(
barX + barWidthPx - tickW,
barY - tickH / 2,
tickW,
tickH,
)
const fontSize = Math.round(unit * 1.4)
ctx.font = `bold ${String(fontSize)}px monospace`
ctx.fillStyle = "#fff"
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText(
`${String(barMm)} mm`,
barX + barWidthPx / 2,
barY - tickH / 2 - Math.round(unit * 0.3),
)
const smallFont = Math.round(unit * 1)
ctx.textAlign = "right"
ctx.textBaseline = "middle"
ctx.font = `${String(smallFont)}px monospace`
ctx.fillStyle = "rgba(255,255,255,0.6)"
// Right-side annotation echoes the canvas-pixel scale so a viewer can
// sanity-check it. We round to 2 decimals because the view-export scale
// can be fractional; integer scales fall through to no-decimal.
const pxPerMmText =
Math.abs(pxPerMm - Math.round(pxPerMm)) < 1e-6
? String(Math.round(pxPerMm))
: pxPerMm.toFixed(2)
ctx.fillText(`${pxPerMmText} px/mm`, iw - margin, barY)
return out
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((b) => {
if (b) resolve(b)
else reject(new Error("toBlob failed"))
}, "image/png")
})
}
// Legacy export: bare image + scale bar, no measurements. Preserved as-is
// for any caller still wired to it (currently none — MeasureViewer's
// addScaleBar handles the no-measurements case directly).
function exportWithScaleBar(): Promise<Blob> {
const image = img.value
if (!image) return Promise.reject(new Error("No image loaded for scale bar export"))
const iw = image.naturalWidth
const ih = image.naturalHeight
const base = document.createElement("canvas")
base.width = iw
base.height = ih
const bctx = base.getContext("2d")
if (!bctx) return Promise.reject(new Error("No 2D context"))
bctx.drawImage(image, 0, 0)
const out = appendScaleBarCanvas(base, props.scalePxPerMm)
return canvasToBlob(out)
}
// New: export the deskewed image with all measurement annotations baked
// in. Two scopes:
// "full": output is the source bitmap at its natural resolution. Stroke
// widths / handle radii / font sizes are scaled up by
// image.naturalWidth / overlay.width so the annotation reads at the
// same visual weight as on screen relative to the image.
// "view": output is the visible viewport at its current canvas pixel
// dimensions. Image + measurements are drawn with the live transform,
// i.e. exactly what the user sees. Stroke widths inherit screen size
// (strokeMul=1).
// Handles, dashed/faded selection styling, and the placement preview are
// suppressed in both — those are interactive UI, not annotation.
//
// Filenames are decided by the caller; the function only returns the blob.
function exportWithMeasurements(opts: {
scope: "full" | "view"
includeScaleBar: boolean
}): Promise<Blob> {
const image = img.value
if (!image) return Promise.reject(new Error("No image loaded for export"))
const out = document.createElement("canvas")
let outCtx: CanvasRenderingContext2D | null
let renderCtx: RenderCtx
let scalePxPerMmForBar: number
if (opts.scope === "full") {
const iw = image.naturalWidth
const ih = image.naturalHeight
out.width = iw
out.height = ih
outCtx = out.getContext("2d")
if (!outCtx) return Promise.reject(new Error("No 2D context"))
outCtx.drawImage(image, 0, 0)
// Scale annotation styling so it reads the same relative to the
// image as on screen. The on-screen overlay canvas width is the
// baseline; if export is twice as wide, strokes / fonts / handles
// double too. Floor at 1 so a tiny overlay doesn't shrink things.
const overlayW = overlayRef.value?.width ?? 1
const strokeMul = Math.max(1, iw / Math.max(1, overlayW))
renderCtx = {
scale: 1,
offsetX: 0,
offsetY: 0,
strokeMul,
drawHandles: false,
drawSelectionDecorations: false,
}
scalePxPerMmForBar = props.scalePxPerMm
} else {
// View export: canvas matches the visible overlay; transform is
// the live one so what's drawn is exactly what the user sees.
const overlayW = overlayRef.value?.width ?? 1
const overlayH = overlayRef.value?.height ?? 1
out.width = overlayW
out.height = overlayH
outCtx = out.getContext("2d")
if (!outCtx) return Promise.reject(new Error("No 2D context"))
// Draw image at the live view transform — same affine the live
// canvasRef is using.
outCtx.save()
outCtx.translate(viewOffsetX.value, viewOffsetY.value)
outCtx.scale(viewScale.value, viewScale.value)
outCtx.drawImage(image, 0, 0)
outCtx.restore()
renderCtx = {
scale: viewScale.value,
offsetX: viewOffsetX.value,
offsetY: viewOffsetY.value,
strokeMul: 1,
drawHandles: false,
drawSelectionDecorations: false,
}
// Effective canvas px per mm = image px per mm × CSS scale, since
// the image is being painted at viewScale into the canvas.
scalePxPerMmForBar = props.scalePxPerMm * viewScale.value
}
// Draw every measurement, no selection distinction. Geometries first,
// then collision-resolved labels — same two-pass order the live
// overlay uses, so labels stay legible when shapes are dense.
for (const m of measurements.value) {
drawMeasurement(outCtx, m, false, renderCtx)
}
const exportLabelPositions = resolveLabelPositions(
outCtx,
measurements.value,
renderCtx,
)
for (const m of measurements.value) {
const pos = exportLabelPositions.get(m.id)
if (pos) drawLabelAt(outCtx, m, false, renderCtx, pos)
}
if (opts.includeScaleBar) {
const withBar = appendScaleBarCanvas(out, scalePxPerMmForBar)
return canvasToBlob(withBar)
}
return canvasToBlob(out)
}
defineExpose({ exportWithScaleBar, exportWithMeasurements })
let resizeObs: ResizeObserver | null = null
onMounted(() => {
seedFromCache()
loadImg()
if (containerRef.value) {
resizeObs = new ResizeObserver(() => {
const c = containerRef.value
if (!c || c.clientWidth === 0) return
fitToContainer()
redraw()
})
resizeObs.observe(containerRef.value)
}
window.addEventListener("keydown", onKeyDown)
})
onUnmounted(() => {
resizeObs?.disconnect()
window.removeEventListener("keydown", onKeyDown)
detachWindowDragListeners()
})
watch(() => props.imageUrl, () => {
// A new image (or the same image with a new object URL) means we should
// re-seed from cache before triggering the redraw chain.
seedFromCache()
loadImg()
})
watch(showGrid, () => { drawOverlay() })
watch(gridSpacingMm, () => { drawOverlay() })
watch(() => props.scalePxPerMm, () => { drawOverlay() })
// Persist on every measurement mutation. localStorage writes are cheap at
// this scale; mirrors how DatumEditor.vue persists store.datums.
watch(
measurements,
(next) => {
if (store.fileHash) {
saveMeasurements(store.fileHash, next)
}
emit("measurements-changed")
},
{ deep: true },
)
// Persist zoom/pan with a small debounce — wheel events fire rapidly and
// resize bursts shouldn't each hit localStorage. The debounce is short
// enough that a normal pan-and-pause finishes saving before navigation.
let zoomSaveTimer: ReturnType<typeof setTimeout> | null = null
watch([viewScale, viewOffsetX, viewOffsetY], () => {
if (!imgLoaded.value || !store.fileHash) return
if (zoomSaveTimer) clearTimeout(zoomSaveTimer)
const hash = store.fileHash
zoomSaveTimer = setTimeout(() => {
saveZoom(hash, {
viewScale: viewScale.value,
viewOffsetX: viewOffsetX.value,
viewOffsetY: viewOffsetY.value,
})
zoomSaveTimer = null
}, 250)
})
</script>
<template>
<!-- In fullscreen mode the viewer becomes a fixed-position overlay that
covers the viewport. The ResizeObserver inside the canvas picks up
the new container size and re-fits automatically. Esc exits. -->
<div
:class="
isFullscreen
? 'fixed inset-0 z-50 space-y-3 overflow-auto bg-background p-4'
: 'space-y-3'
"
>
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2 text-sm">
<div class="inline-flex rounded-md border border-border p-0.5">
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="
activeTool === 'line'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTool('line')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" y1="20" x2="20" y2="4" />
<circle cx="4" cy="20" r="1.5" />
<circle cx="20" cy="4" r="1.5" />
</svg>
Line
</button>
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="
activeTool === 'ellipse'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTool('ellipse')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="12" rx="9" ry="6" />
</svg>
Ellipse
</button>
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="
activeTool === 'circle'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTool('circle')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="8" />
</svg>
Circle
</button>
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="
activeTool === 'rectangle'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTool('rectangle')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="4" y="6" width="16" height="12" rx="1" />
</svg>
Rect
</button>
<button
class="inline-flex items-center gap-1.5 rounded px-2.5 py-1.5 text-xs font-medium transition-colors"
:class="
activeTool === 'angle'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
"
@click="setTool('angle')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 20 L20 20 L4 6 Z" fill="none" />
<path d="M10 20 a6 6 0 0 0 -3.5 -8.5" />
</svg>
Angle
</button>
</div>
<button
v-if="measurements.length > 0 || placementPoints.length > 0"
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:text-destructive"
@click="clearMeasurements"
>
Clear all
</button>
<button
class="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground"
:title="
isFullscreen
? 'Exit fullscreen (Esc)'
: 'Expand to fullscreen'
"
@click="toggleFullscreen"
>
<svg
v-if="!isFullscreen"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 9V3h6" />
<path d="M21 9V3h-6" />
<path d="M3 15v6h6" />
<path d="M21 15v6h-6" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 3v6H3" />
<path d="M15 3v6h6" />
<path d="M9 21v-6H3" />
<path d="M15 21v-6h6" />
</svg>
{{ isFullscreen ? "Exit" : "Fullscreen" }}
</button>
<div class="mx-1 h-4 w-px bg-border" />
<label
class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground"
>
<input
v-model="showGrid"
type="checkbox"
class="accent-primary"
/>
Grid
</label>
<input
v-if="showGrid"
v-model.number="gridSpacingMm"
type="number"
min="1"
max="1000"
class="w-16 rounded-md border border-border bg-transparent px-2 py-1 font-mono text-xs"
/>
<span
v-if="showGrid"
class="font-mono text-xs text-muted-foreground"
>mm</span
>
<label
class="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground"
title="Snap line + angle directions to multiples of 45°"
>
<input
v-model="snapToAngle"
type="checkbox"
class="accent-primary"
/>
Snap 45°
</label>
</div>
<!-- Placement hint -->
<div
v-if="placementHint"
class="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-sm"
>
{{ placementHint }}
<span class="ml-2 text-xs text-muted-foreground">
Press Escape to cancel.
</span>
</div>
<!-- Canvas + side list. Width is dictated by the parent when
rendered inside MeasureViewer the surrounding container spans
the full viewport width; inside DeskewViewer it is capped at
the standard step width. -->
<div class="grid gap-3 md:grid-cols-[1fr_220px]">
<div
ref="containerRef"
class="relative overflow-hidden rounded-lg border border-border bg-muted"
:class="[
canvasHeightClass,
activeTool !== 'none' ? 'cursor-crosshair' : 'cursor-grab',
]"
>
<canvas
ref="canvasRef"
class="absolute inset-0"
/>
<canvas
ref="overlayRef"
class="absolute inset-0 touch-none"
@click="onClick"
@wheel.prevent="onWheel"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseLeave"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
/>
</div>
<!-- Measurement list -->
<div
class="flex flex-col gap-1 overflow-y-auto rounded-lg border border-border bg-muted/30 p-2"
:class="canvasHeightClass"
>
<div
v-if="measurementSummaries.length === 0"
class="px-2 py-3 text-xs text-muted-foreground"
>
No measurements yet. Pick a tool above and click on the image.
</div>
<div
v-for="m in measurementSummaries"
:key="m.id"
role="button"
tabindex="0"
class="group flex cursor-pointer items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:class="
m.selected
? 'border-primary bg-primary/10'
: 'border-transparent hover:border-border hover:bg-muted'
"
@click="selectMeasurement(m.id)"
@keydown.enter.prevent="selectMeasurement(m.id)"
@keydown.space.prevent="selectMeasurement(m.id)"
>
<span
class="inline-block h-3 w-3 shrink-0 rounded-full border border-border"
:style="{ backgroundColor: m.color }"
/>
<span class="shrink-0 font-medium text-foreground">
{{ m.typeLabel }}
</span>
<span class="truncate font-mono text-muted-foreground">
{{ m.label }}
</span>
<button
type="button"
class="ml-auto shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive"
:class="m.selected ? 'opacity-100' : ''"
title="Delete measurement"
@click.stop="deleteMeasurement(m.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>