Eyedropper comparison added for visual review tool. Needs to be tested and finessed on workstation
This commit is contained in:
parent
43051792a3
commit
bd69208a84
16 changed files with 1457 additions and 29 deletions
|
|
@ -148,7 +148,7 @@ model User {
|
|||
feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee")
|
||||
feedbackResolved FeedbackItem[] @relation("FeedbackResolver")
|
||||
feedbackVerified FeedbackItem[] @relation("FeedbackVerifier")
|
||||
colorProbes ColorProbe[]
|
||||
colorProbes ColorProbe[] @relation("ColorProbeCreator")
|
||||
reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator")
|
||||
reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider")
|
||||
|
||||
|
|
@ -744,27 +744,6 @@ model Annotation {
|
|||
@@map("annotations")
|
||||
}
|
||||
|
||||
// ─── Color Probe ────────────────────────────────────────
|
||||
|
||||
model ColorProbe {
|
||||
id String @id @default(cuid())
|
||||
revisionId String
|
||||
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
|
||||
index Int
|
||||
workingX Float
|
||||
workingY Float
|
||||
referenceX Float
|
||||
referenceY Float
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([revisionId, index])
|
||||
@@index([revisionId])
|
||||
@@map("color_probes")
|
||||
}
|
||||
|
||||
// ─── Feedback Item ──────────────────────────────────────
|
||||
|
||||
model FeedbackItem {
|
||||
|
|
@ -855,3 +834,24 @@ model ReviewSessionItem {
|
|||
@@index([sessionId])
|
||||
@@map("review_session_items")
|
||||
}
|
||||
|
||||
// ─── Color Probes (CMF Eyedropper) ─────────────────────
|
||||
|
||||
model ColorProbe {
|
||||
id String @id @default(cuid())
|
||||
revisionId String
|
||||
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
|
||||
index Int // 1-12, display order
|
||||
workingX Float // image-space coordinates on working image
|
||||
workingY Float
|
||||
referenceX Float // image-space coordinates on reference (defaults to same as working)
|
||||
referenceY Float
|
||||
createdById String
|
||||
createdBy User @relation("ColorProbeCreator", fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([revisionId, index])
|
||||
@@index([revisionId])
|
||||
@@map("color_probes")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,6 +227,18 @@ export default function ReviewPage() {
|
|||
return match?.revisionId ?? null;
|
||||
}, [galleryImages, activeImageUrl]);
|
||||
|
||||
// ── Image URLs for CMF probe sampling ──────────────────────────────
|
||||
const { workingImageUrl, referenceImageUrl } = useMemo(() => {
|
||||
if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null };
|
||||
const rev = revisions.find((r: any) => r.id === activeRevisionId);
|
||||
if (!rev) return { workingImageUrl: null, referenceImageUrl: null };
|
||||
const att = rev.attachments as RevisionAttachments | null;
|
||||
return {
|
||||
workingImageUrl: att?.currentImage?.url ?? null,
|
||||
referenceImageUrl: att?.referenceImage?.url ?? null,
|
||||
};
|
||||
}, [activeRevisionId, revisions]);
|
||||
|
||||
// ── Delete annotation (from feedback sidebar) ─────────────────────
|
||||
const deleteAnnotationMutation = useDeleteAnnotation(activeRevisionId);
|
||||
const handleDeleteAnnotation = useCallback(
|
||||
|
|
@ -549,6 +561,8 @@ export default function ReviewPage() {
|
|||
containerHeight={vs.containerHeight}
|
||||
imageDimensions={vs.imageDimensions}
|
||||
hoveredAnnotationId={hoveredAnnotationId}
|
||||
workingImageUrl={workingImageUrl}
|
||||
referenceImageUrl={referenceImageUrl}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
|
||||
import { updateColorProbeSchema } from "@/lib/validators/color-probe";
|
||||
import {
|
||||
updateColorProbe,
|
||||
deleteColorProbe,
|
||||
} from "@/lib/services/color-probe-service";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ revisionId: string; probeId: string }>;
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { probeId } = await ctx.params;
|
||||
const body = await req.json();
|
||||
const parsed = updateColorProbeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.message);
|
||||
}
|
||||
|
||||
const probe = await updateColorProbe(probeId, parsed.data);
|
||||
return NextResponse.json(probe);
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { probeId } = await ctx.params;
|
||||
await deleteColorProbe(probeId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
64
src/app/api/revisions/[revisionId]/color-probes/route.ts
Normal file
64
src/app/api/revisions/[revisionId]/color-probes/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createColorProbeSchema } from "@/lib/validators/color-probe";
|
||||
import {
|
||||
listColorProbes,
|
||||
createColorProbe,
|
||||
clearColorProbes,
|
||||
} from "@/lib/services/color-probe-service";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ revisionId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { revisionId } = await ctx.params;
|
||||
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
const probes = await listColorProbes(revisionId);
|
||||
return NextResponse.json(probes);
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { revisionId } = await ctx.params;
|
||||
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = createColorProbeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.message);
|
||||
}
|
||||
|
||||
const probe = await createColorProbe(revisionId, session!.user.id, parsed.data);
|
||||
return NextResponse.json(probe, { status: 201 });
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { revisionId } = await ctx.params;
|
||||
await clearColorProbes(revisionId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export async function POST(request: Request, { params }: Params) {
|
|||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const revision = await createRevision(stageId, parsed.data);
|
||||
const revision = await createRevision(stageId, parsed.data, session!.user.id);
|
||||
return NextResponse.json(revision, { status: 201 });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
|
||||
import { Send, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type AnnotationShape,
|
||||
} from "@/components/review/annotation-renderer";
|
||||
import { AnnotationTools } from "@/components/review/annotation-tools";
|
||||
import { ColorProbeLayer } from "@/components/review/color-probe-layer";
|
||||
import { useAnnotationState } from "@/hooks/use-annotation-state";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────
|
||||
|
|
@ -25,6 +26,10 @@ export interface AnnotationLayerProps {
|
|||
readOnly?: boolean;
|
||||
/** ID of annotation to highlight (when hovering feedback item) */
|
||||
hoveredAnnotationId?: string | null;
|
||||
/** Working image URL for CMF probe sampling */
|
||||
workingImageUrl?: string | null;
|
||||
/** Reference image URL for CMF probe sampling */
|
||||
referenceImageUrl?: string | null;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────
|
||||
|
|
@ -40,8 +45,11 @@ export function AnnotationLayer({
|
|||
imageDimensions,
|
||||
readOnly = false,
|
||||
hoveredAnnotationId,
|
||||
workingImageUrl,
|
||||
referenceImageUrl,
|
||||
}: AnnotationLayerProps) {
|
||||
const ann = useAnnotationState(revisionId, stageId);
|
||||
const [probesVisible, setProbesVisible] = useState(true);
|
||||
|
||||
// Drag-to-move annotations — works in select mode, dragging any annotation
|
||||
const handleAnnotationDragStart = useCallback(
|
||||
|
|
@ -141,6 +149,8 @@ export function AnnotationLayer({
|
|||
onToggleVisibility={() => ann.setVisible((v: boolean) => !v)}
|
||||
hasSelection={!!ann.selectedId}
|
||||
onDeleteSelection={ann.handleDeleteSelection}
|
||||
probesVisible={probesVisible}
|
||||
onToggleProbes={() => setProbesVisible((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -362,6 +372,22 @@ export function AnnotationLayer({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CMF Color Probe Layer ────────────────────── */}
|
||||
{!readOnly && (
|
||||
<ColorProbeLayer
|
||||
revisionId={revisionId}
|
||||
active={ann.activeTool === "eyedropper"}
|
||||
visible={probesVisible}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
workingImageUrl={workingImageUrl ?? null}
|
||||
referenceImageUrl={referenceImageUrl ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Annotation comment popover ────────────────── */}
|
||||
{!readOnly && ann.pendingAnnotation && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Pencil,
|
||||
Type,
|
||||
MapPin,
|
||||
Pipette,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Eye,
|
||||
|
|
@ -34,7 +35,8 @@ export type AnnotationTool =
|
|||
| "arrow"
|
||||
| "freehand"
|
||||
| "text"
|
||||
| "pin";
|
||||
| "pin"
|
||||
| "eyedropper";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ label: "Red", value: "#EE5540" },
|
||||
|
|
@ -60,6 +62,9 @@ interface AnnotationToolsProps {
|
|||
onToggleVisibility: () => void;
|
||||
hasSelection: boolean;
|
||||
onDeleteSelection: () => void;
|
||||
/** CMF eyedropper probe visibility toggle */
|
||||
probesVisible?: boolean;
|
||||
onToggleProbes?: () => void;
|
||||
}
|
||||
|
||||
const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [
|
||||
|
|
@ -69,6 +74,7 @@ const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?
|
|||
{ id: "freehand", icon: Pencil, label: "Freehand", shortcut: "F" },
|
||||
{ id: "text", icon: Type, label: "Text label", shortcut: "T" },
|
||||
{ id: "pin", icon: MapPin, label: "Pin", shortcut: "P" },
|
||||
{ id: "eyedropper", icon: Pipette, label: "CMF Eyedropper", shortcut: "D" },
|
||||
];
|
||||
|
||||
export function AnnotationTools({
|
||||
|
|
@ -84,6 +90,8 @@ export function AnnotationTools({
|
|||
onToggleVisibility,
|
||||
hasSelection,
|
||||
onDeleteSelection,
|
||||
probesVisible,
|
||||
onToggleProbes,
|
||||
}: AnnotationToolsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -217,6 +225,31 @@ export function AnnotationTools({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* CMF probes visibility toggle */}
|
||||
{onToggleProbes && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
probesVisible && "text-[var(--primary)]"
|
||||
)}
|
||||
onClick={onToggleProbes}
|
||||
>
|
||||
<Pipette className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
!probesVisible && "text-[var(--muted-foreground)]"
|
||||
)} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{probesVisible ? "Hide CMF probes" : "Show CMF probes"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Delete selection */}
|
||||
{hasSelection && (
|
||||
<Tooltip>
|
||||
|
|
|
|||
341
src/components/review/color-probe-layer.tsx
Normal file
341
src/components/review/color-probe-layer.tsx
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ColorProbeMarker, ColorProbeGhostMarker } from "@/components/review/color-probe-marker";
|
||||
import { ColorProbePanel } from "@/components/review/color-probe-panel";
|
||||
import {
|
||||
useColorProbes,
|
||||
useCreateColorProbe,
|
||||
useUpdateColorProbe,
|
||||
useDeleteColorProbe,
|
||||
useClearColorProbes,
|
||||
} from "@/hooks/use-color-probes";
|
||||
import {
|
||||
rgbToHsb,
|
||||
compareHsb,
|
||||
sampleFromImage,
|
||||
type ProbeResult,
|
||||
} from "@/lib/utils/color";
|
||||
|
||||
export interface ColorProbeLayerProps {
|
||||
revisionId: string | null;
|
||||
/** Whether the eyedropper tool is active */
|
||||
active: boolean;
|
||||
/** Whether probes are visible */
|
||||
visible: boolean;
|
||||
/** Viewport state */
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
/** Working image URL (the current render) */
|
||||
workingImageUrl: string | null;
|
||||
/** Reference image URL */
|
||||
referenceImageUrl: string | null;
|
||||
}
|
||||
|
||||
const MAX_PROBES = 12;
|
||||
|
||||
export function ColorProbeLayer({
|
||||
revisionId,
|
||||
active,
|
||||
visible,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
workingImageUrl,
|
||||
referenceImageUrl,
|
||||
}: ColorProbeLayerProps) {
|
||||
const { data: probes = [] } = useColorProbes(revisionId);
|
||||
const createProbe = useCreateColorProbe(revisionId);
|
||||
const updateProbe = useUpdateColorProbe(revisionId);
|
||||
const deleteProbe = useDeleteColorProbe(revisionId);
|
||||
const clearProbes = useClearColorProbes(revisionId);
|
||||
|
||||
const [selectedProbeId, setSelectedProbeId] = useState<string | null>(null);
|
||||
|
||||
// Loaded image refs for sampling
|
||||
const workingImgRef = useRef<HTMLImageElement | null>(null);
|
||||
const referenceImgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// Load working image
|
||||
useEffect(() => {
|
||||
if (!workingImageUrl) {
|
||||
workingImgRef.current = null;
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => { workingImgRef.current = img; };
|
||||
img.src = workingImageUrl;
|
||||
}, [workingImageUrl]);
|
||||
|
||||
// Load reference image
|
||||
useEffect(() => {
|
||||
if (!referenceImageUrl) {
|
||||
referenceImgRef.current = null;
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => { referenceImgRef.current = img; };
|
||||
img.src = referenceImageUrl;
|
||||
}, [referenceImageUrl]);
|
||||
|
||||
// Compute probe results by sampling both images
|
||||
const probeResults = useMemo(() => {
|
||||
const results = new Map<number, ProbeResult>();
|
||||
const workingImg = workingImgRef.current;
|
||||
const refImg = referenceImgRef.current;
|
||||
|
||||
if (!workingImg || !refImg) return results;
|
||||
|
||||
for (const probe of probes) {
|
||||
const workingSample = sampleFromImage(workingImg, probe.workingX, probe.workingY);
|
||||
const refSample = sampleFromImage(refImg, probe.referenceX, probe.referenceY);
|
||||
|
||||
if (workingSample && refSample) {
|
||||
const workingHsb = rgbToHsb(workingSample);
|
||||
const refHsb = rgbToHsb(refSample);
|
||||
results.set(probe.index, compareHsb(workingHsb, refHsb));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
// Re-sample whenever probes or images change
|
||||
}, [probes, workingImageUrl, referenceImageUrl]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle click to place a new probe
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!active || !revisionId) return;
|
||||
if (probes.length >= MAX_PROBES) return;
|
||||
|
||||
const svg = e.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const imgX = (e.clientX - rect.left - panX) / zoom;
|
||||
const imgY = (e.clientY - rect.top - panY) / zoom;
|
||||
|
||||
// Find next available index
|
||||
const usedIndices = new Set(probes.map((p) => p.index));
|
||||
let nextIndex = 1;
|
||||
while (usedIndices.has(nextIndex) && nextIndex <= MAX_PROBES) {
|
||||
nextIndex++;
|
||||
}
|
||||
if (nextIndex > MAX_PROBES) return;
|
||||
|
||||
createProbe.mutate({
|
||||
index: nextIndex,
|
||||
workingX: Math.round(imgX),
|
||||
workingY: Math.round(imgY),
|
||||
referenceX: Math.round(imgX),
|
||||
referenceY: Math.round(imgY),
|
||||
});
|
||||
},
|
||||
[active, revisionId, probes, panX, panY, zoom, createProbe]
|
||||
);
|
||||
|
||||
// Drag handler for working-side probe
|
||||
const handleProbeDrag = useCallback(
|
||||
(probeId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedProbeId(probeId);
|
||||
|
||||
const probe = probes.find((p) => p.id === probeId);
|
||||
if (!probe) return;
|
||||
|
||||
let lastX = e.clientX;
|
||||
let lastY = e.clientY;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - lastX) / zoom;
|
||||
const dy = (me.clientY - lastY) / zoom;
|
||||
lastX = me.clientX;
|
||||
lastY = me.clientY;
|
||||
|
||||
// We update the offset relative to original — accumulate in a ref is better
|
||||
// but for simplicity, we do a debounced update on mouseup
|
||||
probe.workingX += dx;
|
||||
probe.workingY += dy;
|
||||
// Also move reference if not Alt-dragged
|
||||
if (!me.altKey) {
|
||||
probe.referenceX += dx;
|
||||
probe.referenceY += dy;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
updateProbe.mutate({
|
||||
probeId,
|
||||
data: {
|
||||
workingX: Math.round(probe.workingX),
|
||||
workingY: Math.round(probe.workingY),
|
||||
referenceX: Math.round(probe.referenceX),
|
||||
referenceY: Math.round(probe.referenceY),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[probes, zoom, updateProbe]
|
||||
);
|
||||
|
||||
// Drag handler for reference-side ghost marker (Alt+drag to offset)
|
||||
const handleRefDrag = useCallback(
|
||||
(probeId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const probe = probes.find((p) => p.id === probeId);
|
||||
if (!probe) return;
|
||||
|
||||
let lastX = e.clientX;
|
||||
let lastY = e.clientY;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - lastX) / zoom;
|
||||
const dy = (me.clientY - lastY) / zoom;
|
||||
lastX = me.clientX;
|
||||
lastY = me.clientY;
|
||||
probe.referenceX += dx;
|
||||
probe.referenceY += dy;
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
updateProbe.mutate({
|
||||
probeId,
|
||||
data: {
|
||||
referenceX: Math.round(probe.referenceX),
|
||||
referenceY: Math.round(probe.referenceY),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[probes, zoom, updateProbe]
|
||||
);
|
||||
|
||||
// Delete probe on right-click
|
||||
const handleContextMenu = useCallback(
|
||||
(probeId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
deleteProbe.mutate(probeId);
|
||||
},
|
||||
[deleteProbe]
|
||||
);
|
||||
|
||||
// Delete key handler
|
||||
useEffect(() => {
|
||||
if (!selectedProbeId) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
deleteProbe.mutate(selectedProbeId);
|
||||
setSelectedProbeId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedProbeId, deleteProbe]);
|
||||
|
||||
if (!visible || !revisionId) return null;
|
||||
|
||||
// Check if any reference point is offset from working point
|
||||
const hasOffsets = probes.some(
|
||||
(p) =>
|
||||
Math.abs(p.referenceX - p.workingX) > 1 ||
|
||||
Math.abs(p.referenceY - p.workingY) > 1
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* SVG overlay for probe markers */}
|
||||
<svg
|
||||
className="absolute inset-0 z-22"
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
style={{
|
||||
pointerEvents: active ? "auto" : "none",
|
||||
cursor: active ? "crosshair" : "default",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
|
||||
{/* Ghost markers for offset reference points */}
|
||||
{hasOffsets &&
|
||||
probes.map((probe) => {
|
||||
const isOffset =
|
||||
Math.abs(probe.referenceX - probe.workingX) > 1 ||
|
||||
Math.abs(probe.referenceY - probe.workingY) > 1;
|
||||
if (!isOffset) return null;
|
||||
return (
|
||||
<g key={`ref-${probe.id}`} onContextMenu={(e) => e.preventDefault()}>
|
||||
{/* Connecting line */}
|
||||
<line
|
||||
x1={probe.workingX}
|
||||
y1={probe.workingY}
|
||||
x2={probe.referenceX}
|
||||
y2={probe.referenceY}
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
strokeWidth={1 / zoom}
|
||||
strokeDasharray={`${3 / zoom} ${2 / zoom}`}
|
||||
/>
|
||||
<ColorProbeGhostMarker
|
||||
index={probe.index}
|
||||
x={probe.referenceX}
|
||||
y={probe.referenceY}
|
||||
onDragStart={(e) => handleRefDrag(probe.id, e)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Working-side probe markers */}
|
||||
{probes.map((probe) => (
|
||||
<g
|
||||
key={probe.id}
|
||||
onContextMenu={(e) => handleContextMenu(probe.id, e)}
|
||||
>
|
||||
<ColorProbeMarker
|
||||
index={probe.index}
|
||||
x={probe.workingX}
|
||||
y={probe.workingY}
|
||||
result={probeResults.get(probe.index) ?? null}
|
||||
isSelected={selectedProbeId === probe.id}
|
||||
onDragStart={(e) => handleProbeDrag(probe.id, e)}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Summary panel — bottom-left */}
|
||||
{probes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-30">
|
||||
<ColorProbePanel
|
||||
probeResults={probeResults}
|
||||
probeCount={probes.length}
|
||||
onClearAll={() => clearProbes.mutate()}
|
||||
isClearing={clearProbes.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
284
src/components/review/color-probe-marker.tsx
Normal file
284
src/components/review/color-probe-marker.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ProbeResult } from "@/lib/utils/color";
|
||||
|
||||
interface ColorProbeMarkerProps {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
result: ProbeResult | null;
|
||||
isSelected?: boolean;
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pass: "#22C55E",
|
||||
warn: "#F97316",
|
||||
fail: "#EF4444",
|
||||
unknown: "#6B7280",
|
||||
} as const;
|
||||
|
||||
const MARKER_RADIUS = 8;
|
||||
|
||||
export function ColorProbeMarker({
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
result,
|
||||
isSelected,
|
||||
onDragStart,
|
||||
}: ColorProbeMarkerProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const status = result?.status ?? "unknown";
|
||||
const color = STATUS_COLORS[status];
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x}, ${y})`}
|
||||
style={{ cursor: "grab" }}
|
||||
pointerEvents="auto"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
{/* Outer ring */}
|
||||
<circle
|
||||
r={MARKER_RADIUS + 2}
|
||||
fill="none"
|
||||
stroke={isSelected ? "#fff" : "rgba(0,0,0,0.5)"}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
/>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
r={MARKER_RADIUS}
|
||||
fill="#1a1a1a"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* Index number */}
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="9"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
fill="#fff"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{index}
|
||||
</text>
|
||||
|
||||
{/* Status pill — positioned to the right */}
|
||||
<g transform="translate(16, 0)">
|
||||
{/* Compact pill (always visible) */}
|
||||
<rect
|
||||
x={0}
|
||||
y={-6}
|
||||
width={12}
|
||||
height={12}
|
||||
rx={6}
|
||||
fill={color}
|
||||
opacity={0.9}
|
||||
/>
|
||||
|
||||
{/* Expanded detail panel on hover */}
|
||||
{hovered && result && (
|
||||
<g transform="translate(18, -42)">
|
||||
{/* Panel background */}
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={148}
|
||||
height={72}
|
||||
rx={4}
|
||||
fill="rgba(15,15,15,0.92)"
|
||||
stroke="rgba(255,255,255,0.12)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* Header */}
|
||||
<text
|
||||
x={8}
|
||||
y={14}
|
||||
fontSize="8"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
letterSpacing="0.08em"
|
||||
fill="rgba(255,255,255,0.5)"
|
||||
>
|
||||
PROBE {index}
|
||||
</text>
|
||||
<rect
|
||||
x={52}
|
||||
y={6}
|
||||
width={32}
|
||||
height={12}
|
||||
rx={6}
|
||||
fill={color}
|
||||
opacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={68}
|
||||
y={15}
|
||||
textAnchor="middle"
|
||||
fontSize="7"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
fill="#fff"
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* H row */}
|
||||
<HsbRow
|
||||
label="H"
|
||||
working={`${result.working.h}°`}
|
||||
reference={`${result.reference.h}°`}
|
||||
delta={`Δ${result.delta.h.toFixed(1)}°`}
|
||||
pass={result.delta.h <= 4}
|
||||
y={26}
|
||||
/>
|
||||
{/* S row */}
|
||||
<HsbRow
|
||||
label="S"
|
||||
working={`${result.working.s}%`}
|
||||
reference={`${result.reference.s}%`}
|
||||
delta={`Δ${result.delta.s.toFixed(1)}%`}
|
||||
pass={result.delta.s <= 3}
|
||||
y={40}
|
||||
/>
|
||||
{/* B row */}
|
||||
<HsbRow
|
||||
label="B"
|
||||
working={`${result.working.b}%`}
|
||||
reference={`${result.reference.b}%`}
|
||||
delta={`Δ${result.delta.b.toFixed(1)}%`}
|
||||
pass={result.delta.b <= 3}
|
||||
y={54}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function HsbRow({
|
||||
label,
|
||||
working,
|
||||
reference,
|
||||
delta,
|
||||
pass,
|
||||
y,
|
||||
}: {
|
||||
label: string;
|
||||
working: string;
|
||||
reference: string;
|
||||
delta: string;
|
||||
pass: boolean;
|
||||
y: number;
|
||||
}) {
|
||||
return (
|
||||
<g>
|
||||
<text
|
||||
x={8}
|
||||
y={y}
|
||||
fontSize="8"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(255,255,255,0.6)"
|
||||
>
|
||||
{label}:
|
||||
</text>
|
||||
<text
|
||||
x={24}
|
||||
y={y}
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
>
|
||||
{working}
|
||||
</text>
|
||||
<text
|
||||
x={56}
|
||||
y={y}
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(255,255,255,0.45)"
|
||||
>
|
||||
→
|
||||
</text>
|
||||
<text
|
||||
x={66}
|
||||
y={y}
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(255,255,255,0.85)"
|
||||
>
|
||||
{reference}
|
||||
</text>
|
||||
<text
|
||||
x={102}
|
||||
y={y}
|
||||
fontSize="8"
|
||||
fontWeight="600"
|
||||
fontFamily="monospace"
|
||||
fill={pass ? "#22C55E" : "#EF4444"}
|
||||
>
|
||||
{delta}
|
||||
</text>
|
||||
<text
|
||||
x={138}
|
||||
y={y}
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fill={pass ? "#22C55E" : "#EF4444"}
|
||||
>
|
||||
{pass ? "✓" : "✗"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost marker shown on the reference image for an offset probe.
|
||||
*/
|
||||
export function ColorProbeGhostMarker({
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
onDragStart,
|
||||
}: {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x}, ${y})`}
|
||||
style={{ cursor: "grab" }}
|
||||
pointerEvents="auto"
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
<circle
|
||||
r={MARKER_RADIUS}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="3 2"
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="8"
|
||||
fontWeight="600"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(255,255,255,0.6)"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{index}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
175
src/components/review/color-probe-panel.tsx
Normal file
175
src/components/review/color-probe-panel.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"use client";
|
||||
|
||||
import { Trash2, Pipette } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ProbeResult } from "@/lib/utils/color";
|
||||
|
||||
interface ColorProbePanelProps {
|
||||
probeResults: Map<number, ProbeResult>;
|
||||
probeCount: number;
|
||||
onClearAll: () => void;
|
||||
isClearing?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pass: "#22C55E",
|
||||
warn: "#F97316",
|
||||
fail: "#EF4444",
|
||||
} as const;
|
||||
|
||||
export function ColorProbePanel({
|
||||
probeResults,
|
||||
probeCount,
|
||||
onClearAll,
|
||||
isClearing,
|
||||
}: ColorProbePanelProps) {
|
||||
if (probeCount === 0) return null;
|
||||
|
||||
const passCount = Array.from(probeResults.values()).filter(
|
||||
(r) => r.status === "pass"
|
||||
).length;
|
||||
const failCount = Array.from(probeResults.values()).filter(
|
||||
(r) => r.status === "fail"
|
||||
).length;
|
||||
const warnCount = Array.from(probeResults.values()).filter(
|
||||
(r) => r.status === "warn"
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="w-[200px] rounded-lg border bg-[var(--card)]/95 shadow-xl backdrop-blur-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Pipette className="h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
CMF Probes
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={onClearAll}
|
||||
disabled={isClearing}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Clear all probes
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{probeCount} of 12
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{passCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: "rgba(34,197,94,0.15)", color: STATUS_COLORS.pass }}
|
||||
>
|
||||
{passCount}
|
||||
</span>
|
||||
)}
|
||||
{warnCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: "rgba(249,115,22,0.15)", color: STATUS_COLORS.warn }}
|
||||
>
|
||||
{warnCount}
|
||||
</span>
|
||||
)}
|
||||
{failCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: "rgba(239,68,68,0.15)", color: STATUS_COLORS.fail }}
|
||||
>
|
||||
{failCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probe rows */}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{Array.from({ length: probeCount }, (_, i) => i + 1).map((idx) => {
|
||||
const result = probeResults.get(idx);
|
||||
if (!result) return null;
|
||||
const status = result.status;
|
||||
const color = STATUS_COLORS[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 border-b border-[var(--border)]/30 px-3 py-1 last:border-b-0"
|
||||
>
|
||||
{/* Index */}
|
||||
<span className="w-3 text-right font-mono text-[9px] font-bold text-[var(--muted-foreground)]">
|
||||
{idx}
|
||||
</span>
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Deltas */}
|
||||
<div className="flex flex-1 items-center gap-1 font-mono text-[9px]">
|
||||
<DeltaValue
|
||||
label="H"
|
||||
value={result.delta.h}
|
||||
unit="°"
|
||||
pass={result.delta.h <= 4}
|
||||
/>
|
||||
<DeltaValue
|
||||
label="S"
|
||||
value={result.delta.s}
|
||||
unit="%"
|
||||
pass={result.delta.s <= 3}
|
||||
/>
|
||||
<DeltaValue
|
||||
label="B"
|
||||
value={result.delta.b}
|
||||
unit="%"
|
||||
pass={result.delta.b <= 3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaValue({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
pass,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
pass: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span style={{ color: pass ? "rgba(255,255,255,0.5)" : "#EF4444" }}>
|
||||
<span className="opacity-60">{label}</span>
|
||||
{value.toFixed(1)}
|
||||
{unit}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ interface UndoEntry {
|
|||
input?: CreateAnnotationInput;
|
||||
}
|
||||
|
||||
const TOOL_TO_TYPE: Record<Exclude<AnnotationTool, "select">, AnnotationTypeValue> = {
|
||||
const TOOL_TO_TYPE: Record<Exclude<AnnotationTool, "select" | "eyedropper">, AnnotationTypeValue> = {
|
||||
rectangle: "RECTANGLE",
|
||||
ellipse: "ELLIPSE",
|
||||
arrow: "ARROW",
|
||||
|
|
@ -230,7 +230,7 @@ export function useAnnotationState(
|
|||
// Mouse handlers (need zoom/pan passed in)
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, panX: number, panY: number, zoom: number) => {
|
||||
if (activeTool === "select") {
|
||||
if (activeTool === "select" || activeTool === "eyedropper") {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -553,6 +553,7 @@ export function useAnnotationState(
|
|||
case "f": setActiveTool("freehand"); return;
|
||||
case "t": setActiveTool("text"); return;
|
||||
case "p": setActiveTool("pin"); return;
|
||||
case "d": setActiveTool("eyedropper"); return;
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (selectedId) { e.preventDefault(); handleDeleteSelection(); }
|
||||
|
|
|
|||
100
src/hooks/use-color-probes.ts
Normal file
100
src/hooks/use-color-probes.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ColorProbe {
|
||||
id: string;
|
||||
revisionId: string;
|
||||
index: number;
|
||||
workingX: number;
|
||||
workingY: number;
|
||||
referenceX: number;
|
||||
referenceY: number;
|
||||
createdById: string;
|
||||
createdBy: { id: string; name: string | null };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useColorProbes(revisionId: string | null) {
|
||||
return useQuery<ColorProbe[]>({
|
||||
queryKey: ["color-probes", revisionId],
|
||||
queryFn: () => fetchJson(`/api/revisions/${revisionId}/color-probes`),
|
||||
enabled: !!revisionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateColorProbe(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateColorProbeInput) =>
|
||||
fetchJson<ColorProbe>(`/api/revisions/${revisionId}/color-probes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateColorProbe(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
probeId,
|
||||
data,
|
||||
}: {
|
||||
probeId: string;
|
||||
data: UpdateColorProbeInput;
|
||||
}) =>
|
||||
fetchJson<ColorProbe>(
|
||||
`/api/revisions/${revisionId}/color-probes/${probeId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteColorProbe(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (probeId: string) =>
|
||||
fetchJson(`/api/revisions/${revisionId}/color-probes/${probeId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearColorProbes(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
fetchJson(`/api/revisions/${revisionId}/color-probes`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
105
src/lib/services/color-probe-service.ts
Normal file
105
src/lib/services/color-probe-service.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
|
||||
|
||||
/**
|
||||
* List all color probes for a revision, ordered by index.
|
||||
*/
|
||||
export async function listColorProbes(revisionId: string) {
|
||||
return prisma.colorProbe.findMany({
|
||||
where: { revisionId },
|
||||
orderBy: { index: "asc" },
|
||||
include: { createdBy: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new color probe on a revision.
|
||||
*/
|
||||
export async function createColorProbe(
|
||||
revisionId: string,
|
||||
userId: string,
|
||||
input: CreateColorProbeInput
|
||||
) {
|
||||
return prisma.colorProbe.create({
|
||||
data: {
|
||||
revisionId,
|
||||
createdById: userId,
|
||||
index: input.index,
|
||||
workingX: input.workingX,
|
||||
workingY: input.workingY,
|
||||
referenceX: input.referenceX,
|
||||
referenceY: input.referenceY,
|
||||
},
|
||||
include: { createdBy: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a color probe's position.
|
||||
*/
|
||||
export async function updateColorProbe(
|
||||
probeId: string,
|
||||
input: UpdateColorProbeInput
|
||||
) {
|
||||
return prisma.colorProbe.update({
|
||||
where: { id: probeId },
|
||||
data: {
|
||||
...(input.workingX !== undefined && { workingX: input.workingX }),
|
||||
...(input.workingY !== undefined && { workingY: input.workingY }),
|
||||
...(input.referenceX !== undefined && { referenceX: input.referenceX }),
|
||||
...(input.referenceY !== undefined && { referenceY: input.referenceY }),
|
||||
},
|
||||
include: { createdBy: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single color probe.
|
||||
*/
|
||||
export async function deleteColorProbe(probeId: string) {
|
||||
await prisma.colorProbe.delete({ where: { id: probeId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all color probes for a revision (wipe).
|
||||
*/
|
||||
export async function clearColorProbes(revisionId: string) {
|
||||
await prisma.colorProbe.deleteMany({ where: { revisionId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy probes from one revision to another (for R1 → R2 carry-over).
|
||||
* Keeps the same coordinates and indices.
|
||||
*/
|
||||
export async function copyProbes(
|
||||
fromRevisionId: string,
|
||||
toRevisionId: string,
|
||||
userId: string
|
||||
) {
|
||||
const existing = await prisma.colorProbe.findMany({
|
||||
where: { revisionId: fromRevisionId },
|
||||
orderBy: { index: "asc" },
|
||||
});
|
||||
|
||||
if (existing.length === 0) return [];
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
existing.map((probe) =>
|
||||
prisma.colorProbe.create({
|
||||
data: {
|
||||
revisionId: toRevisionId,
|
||||
createdById: userId,
|
||||
index: probe.index,
|
||||
workingX: probe.workingX,
|
||||
workingY: probe.workingY,
|
||||
referenceX: probe.referenceX,
|
||||
referenceY: probe.referenceY,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision";
|
||||
import type { RevisionStatus } from "@/generated/prisma/client";
|
||||
import { copyProbes } from "@/lib/services/color-probe-service";
|
||||
|
||||
/**
|
||||
* Create a new revision round for a stage.
|
||||
|
|
@ -8,7 +9,8 @@ import type { RevisionStatus } from "@/generated/prisma/client";
|
|||
*/
|
||||
export async function createRevision(
|
||||
stageId: string,
|
||||
data: CreateRevisionInput
|
||||
data: CreateRevisionInput,
|
||||
userId?: string
|
||||
) {
|
||||
const lastRevision = await prisma.revision.findFirst({
|
||||
where: { deliverableStageId: stageId },
|
||||
|
|
@ -17,7 +19,7 @@ export async function createRevision(
|
|||
|
||||
const roundNumber = (lastRevision?.roundNumber ?? 0) + 1;
|
||||
|
||||
return prisma.revision.create({
|
||||
const revision = await prisma.revision.create({
|
||||
data: {
|
||||
deliverableStageId: stageId,
|
||||
roundNumber,
|
||||
|
|
@ -27,6 +29,17 @@ export async function createRevision(
|
|||
attachments: data.attachments ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Copy color probes from previous revision (non-blocking)
|
||||
if (lastRevision && userId) {
|
||||
try {
|
||||
await copyProbes(lastRevision.id, revision.id, userId);
|
||||
} catch {
|
||||
// Non-critical: probes are a convenience, not a requirement
|
||||
}
|
||||
}
|
||||
|
||||
return revision;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
209
src/lib/utils/color.ts
Normal file
209
src/lib/utils/color.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Color utilities for CMF eyedropper probes.
|
||||
* RGB ↔ HSB conversion, 5×5 pixel sampling, and delta calculation.
|
||||
*/
|
||||
|
||||
export interface HSB {
|
||||
h: number; // 0-360 degrees
|
||||
s: number; // 0-100 percent
|
||||
b: number; // 0-100 percent
|
||||
}
|
||||
|
||||
export interface RGB {
|
||||
r: number; // 0-255
|
||||
g: number; // 0-255
|
||||
b: number; // 0-255
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
working: HSB;
|
||||
reference: HSB;
|
||||
delta: { h: number; s: number; b: number };
|
||||
status: "pass" | "warn" | "fail";
|
||||
}
|
||||
|
||||
// CMF tolerances
|
||||
const TOLERANCE = { h: 4, s: 3, b: 3 };
|
||||
// Warn when within 1.5× tolerance
|
||||
const WARN_MULTIPLIER = 1.5;
|
||||
|
||||
/**
|
||||
* Convert RGB to HSB (same as Photoshop's HSB).
|
||||
*/
|
||||
export function rgbToHsb(rgb: RGB): HSB {
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
|
||||
// Brightness
|
||||
const brightness = max * 100;
|
||||
|
||||
// Saturation
|
||||
const saturation = max === 0 ? 0 : (delta / max) * 100;
|
||||
|
||||
// Hue
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === r) {
|
||||
hue = ((g - b) / delta) % 6;
|
||||
} else if (max === g) {
|
||||
hue = (b - r) / delta + 2;
|
||||
} else {
|
||||
hue = (r - g) / delta + 4;
|
||||
}
|
||||
hue *= 60;
|
||||
if (hue < 0) hue += 360;
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(hue * 10) / 10,
|
||||
s: Math.round(saturation * 10) / 10,
|
||||
b: Math.round(brightness * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hue delta accounting for circular wraparound.
|
||||
* e.g., 358° vs 2° = Δ4°, not Δ356°.
|
||||
*/
|
||||
export function hueDelta(h1: number, h2: number): number {
|
||||
const diff = Math.abs(h1 - h2);
|
||||
return Math.min(diff, 360 - diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two HSB values and return status.
|
||||
*/
|
||||
export function compareHsb(working: HSB, reference: HSB): ProbeResult {
|
||||
const delta = {
|
||||
h: hueDelta(working.h, reference.h),
|
||||
s: Math.abs(working.s - reference.s),
|
||||
b: Math.abs(working.b - reference.b),
|
||||
};
|
||||
|
||||
const hFail = delta.h > TOLERANCE.h;
|
||||
const sFail = delta.s > TOLERANCE.s;
|
||||
const bFail = delta.b > TOLERANCE.b;
|
||||
|
||||
const hWarn = delta.h > TOLERANCE.h / WARN_MULTIPLIER;
|
||||
const sWarn = delta.s > TOLERANCE.s / WARN_MULTIPLIER;
|
||||
const bWarn = delta.b > TOLERANCE.b / WARN_MULTIPLIER;
|
||||
|
||||
let status: ProbeResult["status"] = "pass";
|
||||
if (hFail || sFail || bFail) {
|
||||
status = "fail";
|
||||
} else if (hWarn || sWarn || bWarn) {
|
||||
status = "warn";
|
||||
}
|
||||
|
||||
return { working, reference, delta, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a 5×5 pixel area from a canvas, averaging RGB values.
|
||||
* Coordinates are in image space.
|
||||
*/
|
||||
export function samplePixels(
|
||||
canvas: HTMLCanvasElement,
|
||||
imageX: number,
|
||||
imageY: number,
|
||||
zoom: number,
|
||||
panX: number,
|
||||
panY: number
|
||||
): RGB | null {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Sample 5×5 grid centered on the point
|
||||
let totalR = 0;
|
||||
let totalG = 0;
|
||||
let totalB = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const imgPx = imageX + dx;
|
||||
const imgPy = imageY + dy;
|
||||
|
||||
// Convert image coords to canvas pixel coords
|
||||
const screenX = (panX + imgPx * zoom) * dpr;
|
||||
const screenY = (panY + imgPy * zoom) * dpr;
|
||||
|
||||
// Bounds check
|
||||
if (screenX < 0 || screenY < 0 || screenX >= canvas.width || screenY >= canvas.height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixel = ctx.getImageData(Math.round(screenX), Math.round(screenY), 1, 1).data;
|
||||
totalR += pixel[0];
|
||||
totalG += pixel[1];
|
||||
totalB += pixel[2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return {
|
||||
r: Math.round(totalR / count),
|
||||
g: Math.round(totalG / count),
|
||||
b: Math.round(totalB / count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample pixels from an image URL by drawing to an offscreen canvas.
|
||||
* Used when we don't have a visible canvas (e.g., reference image in comparison modes).
|
||||
*/
|
||||
export function sampleFromImage(
|
||||
image: HTMLImageElement,
|
||||
imageX: number,
|
||||
imageY: number
|
||||
): RGB | null {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
// Draw white background then image (matches main viewer behavior)
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
let totalR = 0;
|
||||
let totalG = 0;
|
||||
let totalB = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const px = Math.round(imageX + dx);
|
||||
const py = Math.round(imageY + dy);
|
||||
|
||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixel = ctx.getImageData(px, py, 1, 1).data;
|
||||
totalR += pixel[0];
|
||||
totalG += pixel[1];
|
||||
totalB += pixel[2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return {
|
||||
r: Math.round(totalR / count),
|
||||
g: Math.round(totalG / count),
|
||||
b: Math.round(totalB / count),
|
||||
};
|
||||
}
|
||||
20
src/lib/validators/color-probe.ts
Normal file
20
src/lib/validators/color-probe.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
export const createColorProbeSchema = z.object({
|
||||
index: z.number().int().min(1).max(12),
|
||||
workingX: z.number(),
|
||||
workingY: z.number(),
|
||||
referenceX: z.number(),
|
||||
referenceY: z.number(),
|
||||
});
|
||||
|
||||
export type CreateColorProbeInput = z.infer<typeof createColorProbeSchema>;
|
||||
|
||||
export const updateColorProbeSchema = z.object({
|
||||
workingX: z.number().optional(),
|
||||
workingY: z.number().optional(),
|
||||
referenceX: z.number().optional(),
|
||||
referenceY: z.number().optional(),
|
||||
});
|
||||
|
||||
export type UpdateColorProbeInput = z.infer<typeof updateColorProbeSchema>;
|
||||
Loading…
Add table
Reference in a new issue