Skwik/src/components/DatumCanvas.vue
Samuel Prevost da5be3851d feat: world-axis selector, 8-point circle, annotated measurement tool
Datum editor (step 3):
- Add world-axis role to rectangles (isAxisReference) and lines
  (axisRole: "x"|"y"). Exclusive via a new store action that clears
  any other axis flag on write. The solver's pickPrimary now honors an
  explicit user flag ahead of the type-priority fallback; line-primary
  correspondences target world +x or +y depending on the flag.
- Panel UI: checkbox on rect cards, three-way button row on line cards,
  and an axis badge in each card header.
- Ellipse datum switches to 8 user-placed points on the circle contour.
  New src/lib/ellipse-fit.ts does an algebraic LSQ conic fit (data-
  normalised, 5x5 Gaussian solve, f=-1 constraint) and returns the
  geometric center + perpendicular conjugate semi-axes, which we cache
  on the datum for the solver and renderer. Dragging any handle
  refits; an extra center handle translates all 8 points together.
  datum-cache migrates legacy 3-handle storage by synthesising 8
  samples from the old parametric form.
- ResultViewer auto-scale now floors to an int to match the integer-
  only scale input (step=1).

Measurement tool (step 4) — CorrectedImageViewer.vue:
- Three measurement tools: line (length), ellipse (semi-axes + area),
  angle (0-180 degrees between two rays).
- Persistent, multi-measurement state. Each has id, colorIndex, and
  type-specific geometry; colors cycle via the existing getDatumColor
  palette with a monotonic counter so deletion doesn't recolor.
- Selection model with hit-testing on handles, geometry, and labels.
  Selected draws on top in white; others render dashed with 0.8/0.5
  alpha so the active measurement pops.
- Dragging geometry or label moves the whole measurement; dragging a
  handle reshapes just that handle. 3px mouse threshold distinguishes
  click from drag.
- Side panel lists measurements with color chip, type, value, and a
  delete button; clicking selects on canvas. Delete/Backspace deletes
  the selected measurement. Escape cancels in-progress placement.
- Live placement preview + inline hint strip describes what the next
  click does. Pinch-zoom and single-finger pan still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:10:22 +02:00

494 lines
15 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { getDatumColor } from "@/lib/datums"
import type { Datum, Point } from "@/types"
const store = useAppStore()
const containerRef = ref<HTMLDivElement | null>(null)
const stageWidth = ref(800)
const stageHeight = ref(600)
const scale = ref(1)
const offsetX = ref(0)
const offsetY = ref(0)
// Touch state for pinch-to-zoom and pan
let lastPinchDist = 0
let isPanning = false
let isPinching = false
let panStart = { x: 0, y: 0 }
// Track whether a Konva shape is being dragged (touch)
let isDraggingShape = false
const imageConfig = computed(() => {
const img = store.loadedImage
if (!img) return null
return {
image: img,
x: 0,
y: 0,
width: img.naturalWidth,
height: img.naturalHeight,
}
})
const stageDraggable = ref(true)
const stageConfig = computed(() => ({
width: stageWidth.value,
height: stageHeight.value,
scaleX: scale.value,
scaleY: scale.value,
x: offsetX.value,
y: offsetY.value,
draggable: stageDraggable.value,
}))
function datumIndex(datum: Datum): number {
return store.datums.findIndex((d) => d.id === datum.id)
}
function datumPoints(datum: Datum): Point[] {
if (datum.type === "rectangle") return datum.corners as unknown as Point[]
if (datum.type === "line") return datum.endpoints as unknown as Point[]
// Index 0 is the center (translate all); 1..N are the on-curve points
// the user drags to reshape the fitted ellipse.
return [datum.center, ...datum.points]
}
function getPointConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
const points = datumPoints(datum)
const baseRadius = isSelected ? 6 : 4
const visualRadius = Math.max(
baseRadius / scale.value,
baseRadius * 0.5,
)
return points.map((pt, pIdx) => ({
// Ellipse center (index 0) is visually bigger + hollow to distinguish it
x: pt.x,
y: pt.y,
radius:
datum.type === "ellipse" && pIdx === 0
? visualRadius * 1.4
: visualRadius,
fill: datum.type === "ellipse" && pIdx === 0 ? "transparent" : color,
stroke: isSelected ? "#fff" : color,
strokeWidth: (datum.type === "ellipse" && pIdx === 0 ? 2.5 : 1.5) /
scale.value,
draggable: true,
_datumId: datum.id,
_pointIndex: pIdx,
hitStrokeWidth: 12 / scale.value,
}))
}
function ellipseCurvePoints(datum: Datum & { type: "ellipse" }): number[] {
const vAx = datum.axisEndA.x - datum.center.x
const vAy = datum.axisEndA.y - datum.center.y
const vBx = datum.axisEndB.x - datum.center.x
const vBy = datum.axisEndB.y - datum.center.y
const N = 72
const pts: number[] = []
for (let i = 0; i <= N; i++) {
const t = (2 * Math.PI * i) / N
const cs = Math.cos(t)
const sn = Math.sin(t)
pts.push(
datum.center.x + vAx * cs + vBx * sn,
datum.center.y + vAy * cs + vBy * sn,
)
}
return pts
}
function getLineConfigs(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx)
const isSelected = store.selectedDatumId === datum.id
const dash = isSelected ? [] : [8 / scale.value, 4 / scale.value]
const strokeWidth = (isSelected ? 3 : 2) / scale.value
if (datum.type === "line") {
return [
{
points: [
datum.endpoints[0].x,
datum.endpoints[0].y,
datum.endpoints[1].x,
datum.endpoints[1].y,
],
stroke: color,
strokeWidth,
dash,
},
]
}
if (datum.type === "rectangle") {
const c = datum.corners
const pts = [c[0], c[1], c[2], c[3], c[0]].flatMap((p) => [p.x, p.y])
return [
{
points: pts,
stroke: color,
strokeWidth,
closed: true,
dash,
},
]
}
// Ellipse: sampled curve + two thin axis lines for visual reference
return [
{
points: ellipseCurvePoints(datum),
stroke: color,
strokeWidth,
dash,
},
{
points: [
datum.center.x,
datum.center.y,
datum.axisEndA.x,
datum.axisEndA.y,
],
stroke: color,
strokeWidth: 1 / scale.value,
opacity: 0.5,
},
{
points: [
datum.center.x,
datum.center.y,
datum.axisEndB.x,
datum.axisEndB.y,
],
stroke: color,
strokeWidth: 1 / scale.value,
opacity: 0.5,
},
]
}
function getLabelConfig(datum: Datum, dIdx: number) {
const color = getDatumColor(dIdx)
let pos: Point
if (datum.type === "rectangle") {
pos = {
x: (datum.corners[0].x + datum.corners[2].x) / 2,
y: (datum.corners[0].y + datum.corners[2].y) / 2,
}
} else if (datum.type === "line") {
pos = {
x: (datum.endpoints[0].x + datum.endpoints[1].x) / 2,
y:
(datum.endpoints[0].y + datum.endpoints[1].y) / 2 -
20 / scale.value,
}
} else {
pos = {
x: datum.center.x,
y: datum.center.y - 20 / scale.value,
}
}
return {
x: pos.x,
y: pos.y,
text: datum.label,
fontSize: 14 / scale.value,
fill: color,
fontStyle: "bold",
align: "center" as const,
offsetX: (datum.label.length * 7) / 2 / scale.value,
}
}
function onPointDragMove(e: {
target: {
x: () => number
y: () => number
attrs: { _datumId: string; _pointIndex: number }
}
}) {
const { _datumId, _pointIndex } = e.target.attrs
const datum = store.datums.find((d) => d.id === _datumId)
if (!datum) return
const newPos: Point = { x: e.target.x(), y: e.target.y() }
if (datum.type === "rectangle") {
const newCorners = [...datum.corners] as [Point, Point, Point, Point]
newCorners[_pointIndex] = newPos
store.updateDatum(_datumId, { corners: newCorners })
} else if (datum.type === "line") {
const newEndpoints = [...datum.endpoints] as [Point, Point]
newEndpoints[_pointIndex] = newPos
store.updateDatum(_datumId, { endpoints: newEndpoints })
} else if (_pointIndex === 0) {
// Ellipse center — translate all on-curve points by the same delta
// and let the store refit.
const dx = newPos.x - datum.center.x
const dy = newPos.y - datum.center.y
const translated = datum.points.map((p) => ({
x: p.x + dx,
y: p.y + dy,
}))
store.updateEllipsePoints(_datumId, translated)
} else {
const newPoints = datum.points.map((p, i) =>
i === _pointIndex - 1 ? newPos : p,
)
store.updateEllipsePoints(_datumId, newPoints)
}
}
function onPointClick(datumId: string) {
store.selectedDatumId = datumId
}
// Zoom with mouse wheel
function onWheel(e: WheelEvent) {
e.preventDefault()
const scaleBy = 1.08
const oldScale = scale.value
const newScale = e.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy
const clampedScale = Math.max(0.05, Math.min(10, newScale))
const rect = containerRef.value?.getBoundingClientRect()
if (!rect) return
const pointerX = e.clientX - rect.left
const pointerY = e.clientY - rect.top
const mousePointTo = {
x: (pointerX - offsetX.value) / oldScale,
y: (pointerY - offsetY.value) / oldScale,
}
scale.value = clampedScale
offsetX.value = pointerX - mousePointTo.x * clampedScale
offsetY.value = pointerY - mousePointTo.y * clampedScale
}
// Touch handlers for pinch-to-zoom and pan
function getTouchDistance(t1: Touch, t2: Touch): number {
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
}
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
return {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2,
}
}
let pendingPanTouch: { x: number; y: number } | null = null
function onTouchStart(e: TouchEvent) {
if (e.touches.length === 2) {
e.preventDefault()
isPanning = false
isDraggingShape = false
isPinching = true
pendingPanTouch = null
// Disable stage drag so Konva doesn't fight with pinch-zoom
stageDraggable.value = false
const t0 = e.touches[0]
const t1 = e.touches[1]
if (t0 && t1) {
lastPinchDist = getTouchDistance(t0, t1)
}
} else if (e.touches.length === 1) {
// Record the touch but don't start panning yet —
// give Konva a chance to claim it as a shape drag.
const t0 = e.touches[0]
if (!t0) return
pendingPanTouch = { x: t0.clientX, y: t0.clientY }
isPanning = false
}
}
function onTouchMove(e: TouchEvent) {
if (e.touches.length === 2) {
e.preventDefault()
const t0 = e.touches[0]
const t1 = e.touches[1]
if (!t0 || !t1) return
const dist = getTouchDistance(t0, t1)
const center = getTouchCenter(t0, t1)
const rect = containerRef.value?.getBoundingClientRect()
if (!rect) return
const scaleFactor = dist / lastPinchDist
const oldScale = scale.value
const newScale = Math.max(0.05, Math.min(10, oldScale * scaleFactor))
const cx = center.x - rect.left
const cy = center.y - rect.top
const mousePointTo = {
x: (cx - offsetX.value) / oldScale,
y: (cy - offsetY.value) / oldScale,
}
scale.value = newScale
offsetX.value = cx - mousePointTo.x * newScale
offsetY.value = cy - mousePointTo.y * newScale
lastPinchDist = dist
} else if (e.touches.length === 1 && !isDraggingShape) {
const t0 = e.touches[0]
if (!t0) return
// If we haven't started panning yet, promote the pending touch
if (!isPanning && pendingPanTouch) {
isPanning = true
panStart = {
x: pendingPanTouch.x - offsetX.value,
y: pendingPanTouch.y - offsetY.value,
}
pendingPanTouch = null
}
if (isPanning) {
offsetX.value = t0.clientX - panStart.x
offsetY.value = t0.clientY - panStart.y
}
}
}
function onTouchEnd() {
lastPinchDist = 0
isPanning = false
isDraggingShape = false
pendingPanTouch = null
if (isPinching) {
isPinching = false
// Re-enable stage drag after pinch ends
stageDraggable.value = true
}
}
function onPointDragStart() {
isDraggingShape = true
isPanning = false
pendingPanTouch = null
stageDraggable.value = false
}
function onPointDragEnd() {
isDraggingShape = false
stageDraggable.value = true
}
function onStageDragEnd(e: { target: { x: () => number; y: () => number; nodeType: string } }) {
// Only sync offset when the stage itself was dragged, not a child shape
if (e.target.nodeType !== "Stage") return
offsetX.value = e.target.x()
offsetY.value = e.target.y()
}
// Fit image to canvas on mount
function fitToCanvas() {
const img = store.loadedImage
const container = containerRef.value
if (!img || !container) return
const cw = container.clientWidth
const ch = container.clientHeight
stageWidth.value = cw
stageHeight.value = ch
const fitScale =
Math.min(cw / img.naturalWidth, ch / img.naturalHeight) * 0.9
scale.value = fitScale
offsetX.value = (cw - img.naturalWidth * fitScale) / 2
offsetY.value = (ch - img.naturalHeight * fitScale) / 2
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
fitToCanvas()
if (containerRef.value) {
resizeObserver = new ResizeObserver(() => {
if (!containerRef.value) return
const cw = containerRef.value.clientWidth
const ch = containerRef.value.clientHeight
// Skip if the container is hidden (0-sized)
if (cw === 0 || ch === 0) return
// Re-fit whenever the container size actually changes
fitToCanvas()
})
resizeObserver.observe(containerRef.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
watch(() => store.loadedImage, fitToCanvas)
</script>
<template>
<div
ref="containerRef"
class="h-full w-full cursor-grab overflow-hidden rounded-lg border border-border bg-muted active:cursor-grabbing"
style="touch-action: none"
@wheel.prevent="onWheel"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<v-stage :config="stageConfig" @dragend="onStageDragEnd">
<v-layer>
<!-- Background image -->
<v-image v-if="imageConfig" :config="imageConfig" />
<!-- Datum shapes -->
<template v-for="datum in store.datums" :key="datum.id">
<!-- Lines/edges -->
<v-line
v-for="(lineCfg, li) in getLineConfigs(
datum,
datumIndex(datum),
)"
:key="`${datum.id}-line-${li}`"
:config="lineCfg"
/>
<!-- Center label -->
<v-text
:config="getLabelConfig(datum, datumIndex(datum))"
/>
<!-- Draggable points -->
<v-circle
v-for="ptCfg in getPointConfigs(
datum,
datumIndex(datum),
)"
:key="`${datum.id}-pt-${ptCfg._pointIndex}`"
:config="ptCfg"
@dragstart="onPointDragStart"
@dragmove="onPointDragMove"
@dragend="onPointDragEnd"
@click="onPointClick(datum.id)"
@tap="onPointClick(datum.id)"
/>
</template>
</v-layer>
</v-stage>
</div>
</template>