feat(datums): broaden image upload, swap W/H, validate rect corners
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

- Accept any browser-supported image type (png, webp, gif, …) in addition
  to jpg and heic on the upload step.
- Add a swap button between the width and height inputs on rectangle
  datums so users can flip dimensions with one click.
- Block the Next action on the datum step when a rectangle has crossed
  corners (top below bottom or right left of left), and surface which
  datums need fixing in the tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Prevost 2026-04-23 20:39:37 +02:00
parent 27b23a61d9
commit a71c8c73ef
4 changed files with 75 additions and 10 deletions

View File

@ -3,6 +3,7 @@ import { ref, computed, watch } from "vue"
import { useMediaQuery } from "@vueuse/core" import { useMediaQuery } from "@vueuse/core"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { saveDatums } from "@/lib/datum-cache" import { saveDatums } from "@/lib/datum-cache"
import { isRectCrossed } from "@/lib/datums"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@ -36,13 +37,34 @@ const incompleteDatums = computed(() =>
}), }),
) )
const crossedRects = computed(() =>
store.datums.filter(
(d) => d.type === "rectangle" && isRectCrossed(d),
),
)
const canGoNext = computed(
() => store.canProceedToStep4 && crossedRects.value.length === 0,
)
const nextTooltip = computed(() => { const nextTooltip = computed(() => {
if (store.datums.length === 0) return "Add at least one datum" if (store.datums.length === 0) return "Add at least one datum"
if (incompleteDatums.value.length === 0) return "" if (incompleteDatums.value.length > 0) {
const names = incompleteDatums.value.map((d) => d.label) const names = incompleteDatums.value.map((d) => d.label)
return `Missing dimensions: ${names.join(", ")}` return `Missing dimensions: ${names.join(", ")}`
}
if (crossedRects.value.length > 0) {
const names = crossedRects.value.map((d) => d.label)
return `Crossed corners — fix corner order on: ${names.join(", ")}`
}
return ""
}) })
function handleNext() {
if (!canGoNext.value) return
store.goToStep(4)
}
watch( watch(
() => store.datums, () => store.datums,
(datums) => { (datums) => {
@ -74,8 +96,8 @@ watch(
<span class="inline-flex"> <span class="inline-flex">
<Button <Button
size="sm" size="sm"
:disabled="!store.canProceedToStep4" :disabled="!canGoNext"
@click="store.goToStep(4)" @click="handleNext"
> >
Next Next
</Button> </Button>

View File

@ -46,6 +46,13 @@ function updateField(datum: Datum, field: string, value: string | number) {
store.updateDatum(datum.id, { [field]: value }) store.updateDatum(datum.id, { [field]: value })
} }
function swapRectDims(datum: RectDatum) {
store.updateDatum(datum.id, {
widthMm: datum.heightMm,
heightMm: datum.widthMm,
})
}
function updateConfidence(datum: Datum, val: number[] | undefined) { function updateConfidence(datum: Datum, val: number[] | undefined) {
if (!val) return if (!val) return
const v = val[0] const v = val[0]
@ -191,7 +198,7 @@ function formatDimensions(datum: Datum): string {
<!-- Dimensions --> <!-- Dimensions -->
<div <div
v-if="datum.type === 'rectangle'" v-if="datum.type === 'rectangle'"
class="grid grid-cols-2 gap-2" class="grid grid-cols-[1fr_auto_1fr] items-end gap-2"
> >
<div> <div>
<Label class="text-xs">Width (mm)</Label> <Label class="text-xs">Width (mm)</Label>
@ -209,6 +216,31 @@ function formatDimensions(datum: Datum): string {
@click.stop @click.stop
/> />
</div> </div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 text-muted-foreground"
title="Swap width and height"
aria-label="Swap width and height"
@click.stop="swapRectDims(datum as RectDatum)"
>
<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"
>
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a2 2 0 0 1 2-2h16" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a2 2 0 0 1-2 2H3" />
</svg>
</Button>
<div> <div>
<Label class="text-xs">Height (mm)</Label> <Label class="text-xs">Height (mm)</Label>
<Input <Input

View File

@ -21,7 +21,7 @@ const error = ref("")
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const cacheCount = ref(0) const cacheCount = ref(0)
const ACCEPTED = ".jpg,.jpeg,.heic,.heif" const ACCEPTED = "image/*,.heic,.heif"
onMounted(() => { onMounted(() => {
cacheCount.value = getCacheSize() cacheCount.value = getCacheSize()
@ -94,8 +94,8 @@ function onFileSelect(e: Event) {
<CardHeader class="text-center"> <CardHeader class="text-center">
<CardTitle class="text-lg">Load Source Image</CardTitle> <CardTitle class="text-lg">Load Source Image</CardTitle>
<CardDescription> <CardDescription>
Drop a JPG or HEIC file, or click to browse. HEIC Drop any image (JPG, PNG, WebP, HEIC, ), or click
is converted automatically. to browse. HEIC is converted automatically.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -147,7 +147,7 @@ function onFileSelect(e: Event) {
<p <p
class="mt-1 font-mono text-xs text-muted-foreground/60" class="mt-1 font-mono text-xs text-muted-foreground/60"
> >
.jpg .jpeg .heic .heif .jpg .jpeg .png .webp .gif .heic .heif
</p> </p>
</template> </template>

View File

@ -48,6 +48,17 @@ export function createRectDatum(
} }
} }
/**
* Rectangle corners are stored as [TL, TR, BR, BL]. A rectangle is
* "crossed" when a user has dragged corners past each other so the ordering
* no longer holds: top corners must sit above bottom corners (smaller y in
* image coordinates), and right corners must sit right of left corners.
*/
export function isRectCrossed(rect: RectDatum): boolean {
const [tl, tr, br, bl] = rect.corners
return tl.y >= bl.y || tr.y >= br.y || tl.x >= tr.x || bl.x >= br.x
}
export function createLineDatum(center: Point, index: number): LineDatum { export function createLineDatum(center: Point, index: number): LineDatum {
const spread = 100 const spread = 100
return { return {