From 514b42ea91ac47e552795074f4f7ad00488b5080 Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Fri, 31 Jul 2026 19:02:25 +0530 Subject: [PATCH] refactor(ticket): use video detections for assignment map and remove coordinates API --- .../assign/components/AssignmentIssuesMap.tsx | 257 ++++++++++++------ .../assign/components/ChooseIssuesSection.tsx | 125 ++++----- .../ticket/[ticketId]/assign/page.tsx | 3 + .../ticket/hooks/useTicketQueries.ts | 42 +-- .../(modules)/ticket/queries/ticketKeys.ts | 15 +- src/app/globals.css | 24 +- src/constants/apiRoutes.ts | 1 - src/services/api/detection.service.ts | 32 --- src/types/detection.ts | 32 --- 9 files changed, 257 insertions(+), 274 deletions(-) diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx index ca33c73..7566bac 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/AssignmentIssuesMap.tsx @@ -5,11 +5,7 @@ import dynamic from 'next/dynamic'; import { AlertTriangle, MapPinned, RefreshCw } from 'lucide-react'; import { getDefectVisual } from '@/constants/defectVisualConfig'; -import type { - DetectionCoordinateClass, - DetectionCoordinateItem, - DetectionCoordinatesResponse, -} from '@/types'; +import type { DetectionResultItem, VideoDetectionsResponse } from '@/types'; import { Button } from '@/components/ui/button'; import { Card, @@ -20,51 +16,80 @@ import { } from '@/components/ui/card'; import type { AssignmentRangePoint } from './assignmentRange'; -import { AssignmentRangeActions } from './AssignmentRangeActions'; type AssignmentIssuesMapProps = { - data?: DetectionCoordinatesResponse; + data?: VideoDetectionsResponse; isLoading: boolean; - isFetchingMore: boolean; isError: boolean; - hasMore: boolean; startPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null; - onSetStart: (point: AssignmentRangePoint) => void; - onSetEnd: (point: AssignmentRangePoint) => void; - onLoadMore: () => void; onRetry: () => void; }; +type AssignmentMapItem = { + id: number; + class_name: string; + display_name: string; + latitude: number; + longitude: number; + confidence: number; + sequence: number; +}; + +type AssignmentMapClass = { + class_name: string; + display_name: string; +}; + type ClientAssignmentIssuesMapProps = { - items: DetectionCoordinateItem[]; - classes: DetectionCoordinateClass[]; + items: AssignmentMapItem[]; + classes: AssignmentMapClass[]; selectedDetectionId: number | null; startPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null; onSelectDetection: (detectionId: number) => void; - onSetStart: (point: AssignmentRangePoint) => void; - onSetEnd: (point: AssignmentRangePoint) => void; }; -function isValidCoordinate(item: DetectionCoordinateItem) { - return ( - Number.isFinite(item.latitude) && - Number.isFinite(item.longitude) && - item.latitude >= -90 && - item.latitude <= 90 && - item.longitude >= -180 && - item.longitude <= 180 - ); +function toMapItem(item: DetectionResultItem): AssignmentMapItem | null { + const { latitude, longitude } = item.location; + if ( + typeof latitude !== 'number' || + typeof longitude !== 'number' || + !Number.isFinite(latitude) || + !Number.isFinite(longitude) || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + return null; + } + + return { + id: item.detection.id, + class_name: item.detection.class_name, + display_name: item.detection.display_name, + latitude, + longitude, + confidence: item.detection.confidence, + sequence: item.frame.timestamp_seconds, + }; } const ClientAssignmentIssuesMap = dynamic( async () => { - const { CircleMarker, MapContainer, Popup, TileLayer, Tooltip, useMap } = - await import('react-leaflet'); + const { + CircleMarker, + MapContainer, + Polyline, + Popup, + TileLayer, + Tooltip, + useMap, + } = await import('react-leaflet'); const { canvas } = await import('leaflet'); - function FitMapToIssues({ items }: { items: DetectionCoordinateItem[] }) { + function FitMapToIssues({ items }: { items: AssignmentMapItem[] }) { const map = useMap(); useEffect(() => { @@ -96,8 +121,6 @@ const ClientAssignmentIssuesMap = dynamic( startPoint, endPoint, onSelectDetection, - onSetStart, - onSetEnd, }: ClientAssignmentIssuesMapProps) { const markerRenderer = useMemo(() => canvas({ tolerance: 12 }), []); const displayNames = new Map( @@ -107,6 +130,27 @@ const ClientAssignmentIssuesMap = dynamic( items[0].latitude, items[0].longitude, ]; + const orderedItems = [...items].sort( + (first, second) => + first.sequence - second.sequence || first.id - second.id, + ); + const startIndex = orderedItems.findIndex( + (item) => item.id === startPoint?.detectionId, + ); + const endIndex = orderedItems.findIndex( + (item) => item.id === endPoint?.detectionId, + ); + const hasSelectedRange = startIndex >= 0 && endIndex >= 0; + const rangeItems = hasSelectedRange + ? orderedItems.slice( + Math.min(startIndex, endIndex), + Math.max(startIndex, endIndex) + 1, + ) + : []; + const rangeDetectionIds = new Set(rangeItems.map((item) => item.id)); + const rangePath = rangeItems.map( + (item) => [item.latitude, item.longitude] as [number, number], + ); return ( ( attribution="© OpenStreetMap contributors" url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> - + + + {rangePath.length >= 2 ? ( + <> + + + + ) : null} {items.map((item) => { const visual = getDefectVisual(item.class_name); @@ -127,35 +194,37 @@ const ClientAssignmentIssuesMap = dynamic( const isSelected = selectedDetectionId === item.id; const isStart = startPoint?.detectionId === item.id; const isEnd = endPoint?.detectionId === item.id; - const rangeLabel = isStart ? 'A' : isEnd ? 'B' : null; + const rangeLabel = isStart ? 'Start' : isEnd ? 'End' : null; const rangeColor = isStart ? '#16a34a' : isEnd ? '#dc2626' : visual.boundingBoxColor; const isRangeIssue = isStart || isEnd; + const isInsideRange = rangeDetectionIds.has(item.id); const displayName = displayNames.get(item.class_name) ?? + item.display_name ?? item.class_name.replaceAll('_', ' '); - const point: AssignmentRangePoint = { - detectionId: item.id, - latitude: item.latitude, - longitude: item.longitude, - }; - return ( (

{rangeLabel ? ( - + + {rangeLabel} ) : null} @@ -213,22 +282,17 @@ const ClientAssignmentIssuesMap = dynamic( - -
- -
{rangeLabel ? ( - - {rangeLabel} + + {rangeLabel} ) : null}
@@ -247,25 +311,38 @@ const ClientAssignmentIssuesMap = dynamic( export function AssignmentIssuesMap({ data, isLoading, - isFetchingMore, isError, - hasMore, startPoint, endPoint, - onSetStart, - onSetEnd, - onLoadMore, onRetry, }: AssignmentIssuesMapProps) { const [selectedDetectionId, setSelectedDetectionId] = useState( null, ); const validItems = useMemo( - () => (data?.items ?? []).filter(isValidCoordinate), + () => + (data?.items ?? []).flatMap((item) => { + const mapItem = toMapItem(item); + return mapItem ? [mapItem] : []; + }), [data?.items], ); - const total = data?.total ?? 0; - const loadedCount = Math.min(data?.items.length ?? 0, total); + const classes = useMemo( + () => + Array.from( + new Map( + validItems.map((item) => [ + item.class_name, + { + class_name: item.class_name, + display_name: item.display_name, + }, + ]), + ).values(), + ), + [validItems], + ); + const pageItemCount = data?.items.length ?? 0; useEffect(() => { if ( @@ -287,7 +364,7 @@ export function AssignmentIssuesMap({
Issues on Map - Click a marker, then choose Start or End. + Select Start and End in the table to preview the covered range.
@@ -329,13 +406,11 @@ export function AssignmentIssuesMap({
@@ -344,7 +419,7 @@ export function AssignmentIssuesMap({ aria-label="Map legend" > Map legend - {(data?.classes ?? []).map((item) => { + {classes.map((item) => { const visual = getDefectVisual(item.class_name); const IssueIcon = visual.icon; @@ -366,27 +441,33 @@ export function AssignmentIssuesMap({ ); })} + {startPoint ? ( +
+ + Start +
+ ) : null} + {endPoint ? ( +
+ + End +
+ ) : null} + {startPoint && endPoint ? ( +
+ + + + Coverage area +
+ ) : null} -
+
- Showing {loadedCount} of {total} + {validItems.length} mapped from {pageItemCount} cached{' '} + {pageItemCount === 1 ? 'issue' : 'issues'} - {hasMore ? ( - - ) : ( - - All issues are shown - - )}
)} diff --git a/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx b/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx index 8d09b2e..7be4c15 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/components/ChooseIssuesSection.tsx @@ -1,17 +1,15 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { X } from 'lucide-react'; +import { FilterX } from 'lucide-react'; import { MultiSelectPopover } from '@/components/form/MultiSelectPopover'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import type { VideoDetectionsResponse } from '@/types'; -import { - useDetectionCoordinatesQuery, - useTicketDetectionsQuery, -} from '../../../hooks/useTicketQueries'; +import { useTicketDetectionsQuery } from '../../../hooks/useTicketQueries'; import { formatCompactRangeCoordinate, formatRangeCoordinate, @@ -22,7 +20,6 @@ import { useIssueColumns } from './IssueColumns'; import { IssueTable } from './IssueTable'; const DEFAULT_PAGE_SIZE = 10; -const MAP_PAGE_SIZE = 50; interface IssueTypeOption { class_name: string; @@ -34,6 +31,7 @@ interface ChooseIssuesSectionProps { videoId?: string; issueTypes: IssueTypeOption[]; selectedIssueTypes: string[]; + cacheRevision: number; startPoint: AssignmentRangePoint | null; endPoint: AssignmentRangePoint | null; onSelectedIssueTypesChange: (values: string[]) => void; @@ -45,6 +43,7 @@ export function ChooseIssuesSection({ videoId, issueTypes, selectedIssueTypes, + cacheRevision, startPoint, endPoint, onSelectedIssueTypesChange, @@ -84,15 +83,21 @@ export function ChooseIssuesSection({ ); const handleIssueTypesChange = useCallback( (values: string[]) => { + setSkip(0); onSelectedIssueTypesChange(values); onStartPointChange(null); onEndPointChange(null); }, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange], ); - useEffect(() => { + const hasActiveFilters = + selectedIssueTypes.length > 0 || startPoint !== null || endPoint !== null; + const clearFilters = useCallback(() => { setSkip(0); - }, [selectedIssueTypes]); + onSelectedIssueTypesChange([]); + onStartPointChange(null); + onEndPointChange(null); + }, [onEndPointChange, onSelectedIssueTypesChange, onStartPointChange]); const queryParams = useMemo( () => ({ @@ -105,39 +110,56 @@ export function ChooseIssuesSection({ [limit, selectedIssueTypes, skip], ); const detectionsQuery = useTicketDetectionsQuery(videoId, queryParams); - const mapQueryParams = useMemo( - () => ({ - limit: MAP_PAGE_SIZE, - class_name: - selectedIssueTypes.length > 0 ? selectedIssueTypes : undefined, - assignment_status: 'unassigned' as const, - }), - [selectedIssueTypes], - ); - const coordinatesQuery = useDetectionCoordinatesQuery( - videoId, - mapQueryParams, - ); const result = detectionsQuery.data; - const mapData = useMemo(() => { - const pages = coordinatesQuery.data?.pages; + const mapCacheKey = useMemo( + () => JSON.stringify([videoId ?? '', selectedIssueTypes, cacheRevision]), + [cacheRevision, selectedIssueTypes, videoId], + ); + const [mapCache, setMapCache] = useState< + Record + >({}); - if (!pages?.length) return undefined; + useEffect(() => { + if (!result) return; - const latestPage = pages[pages.length - 1]; + setMapCache((current) => { + const itemsById = new Map( + (current[mapCacheKey]?.items ?? []).map((item) => [ + item.detection.id, + item, + ]), + ); + result.items.forEach((item) => itemsById.set(item.detection.id, item)); - return { - ...latestPage, - items: pages.flatMap((page) => page.items), - truncated: Boolean(coordinatesQuery.hasNextPage), - }; - }, [coordinatesQuery.data?.pages, coordinatesQuery.hasNextPage]); + return { + ...current, + [mapCacheKey]: { + ...result, + skip: 0, + limit: itemsById.size, + items: Array.from(itemsById.values()), + }, + }; + }); + }, [mapCacheKey, result]); + + const mapData = mapCache[mapCacheKey]; return (
- + Filters +
@@ -177,40 +199,10 @@ export function ChooseIssuesSection({ ) : null} - - {startPoint !== null || endPoint !== null ? ( - - ) : null}
- void coordinatesQuery.fetchNextPage()} - onRetry={() => void coordinatesQuery.refetch()} - /> - + + void detectionsQuery.refetch()} + />
); } diff --git a/src/app/(modules)/ticket/[ticketId]/assign/page.tsx b/src/app/(modules)/ticket/[ticketId]/assign/page.tsx index 9dfdf8d..03af3c3 100644 --- a/src/app/(modules)/ticket/[ticketId]/assign/page.tsx +++ b/src/app/(modules)/ticket/[ticketId]/assign/page.tsx @@ -42,6 +42,7 @@ export default function TicketAssignmentPage() { ); const [endPoint, setEndPoint] = useState(null); const [selectedIssueTypes, setSelectedIssueTypes] = useState([]); + const [assignmentRevision, setAssignmentRevision] = useState(0); const overviewQuery = useTicketOverviewQuery(ticketId); const overview = overviewQuery.data; const videoId = overview?.video_id ?? overview?.video?.id ?? undefined; @@ -109,6 +110,7 @@ export default function TicketAssignmentPage() { videoId={videoId} issueTypes={overview.ai_result.detections_by_class} selectedIssueTypes={selectedIssueTypes} + cacheRevision={assignmentRevision} startPoint={startPoint} endPoint={endPoint} onSelectedIssueTypesChange={setSelectedIssueTypes} @@ -151,6 +153,7 @@ export default function TicketAssignmentPage() { onAssigned={() => { setStartPoint(null); setEndPoint(null); + setAssignmentRevision((revision) => revision + 1); }} /> diff --git a/src/app/(modules)/ticket/hooks/useTicketQueries.ts b/src/app/(modules)/ticket/hooks/useTicketQueries.ts index 5aed3da..3aea994 100644 --- a/src/app/(modules)/ticket/hooks/useTicketQueries.ts +++ b/src/app/(modules)/ticket/hooks/useTicketQueries.ts @@ -13,7 +13,6 @@ import { detectionService, ticketService, videoService } from '@/services/api'; import type { AssignTicketPayload, CloseTicketPayload, - DetectionCoordinatesParams, DiscardDetectionPayload, RequestTicketExtensionPayload, ReviewDetectionRepairProofPayload, @@ -126,43 +125,7 @@ export function useTicketDetectionsQuery( queryFn: () => videoService.getVideoDetections(videoId as string, queryParams), enabled: Boolean(videoId), - }); -} - -export function useDetectionCoordinatesQuery( - videoId: string | undefined, - params: DetectionCoordinatesParams, -) { - const queryParams = useMemo( - () => ({ - limit: params.limit ?? 50, - class_name: params.class_name, - assignment_status: params.assignment_status, - search: params.search, - }), - [params.assignment_status, params.class_name, params.limit, params.search], - ); - - return useInfiniteQuery({ - queryKey: ticketKeys.detectionCoordinates(videoId ?? '', queryParams), - queryFn: ({ pageParam }) => - detectionService.getCoordinates(videoId as string, { - ...queryParams, - skip: pageParam, - }), - initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - if (lastPage.items.length === 0) return undefined; - - const loadedCount = allPages.reduce( - (total, page) => total + page.items.length, - 0, - ); - - return loadedCount < lastPage.total ? loadedCount : undefined; - }, - enabled: Boolean(videoId), - staleTime: 60 * 1000, + staleTime: Infinity, }); } @@ -320,9 +283,6 @@ export function useAssignTicketMutation(ticketId: string, videoId?: string) { queryClient.invalidateQueries({ queryKey: ticketKeys.classDetectionLists(videoId), }), - queryClient.invalidateQueries({ - queryKey: ticketKeys.detectionCoordinateLists(videoId), - }), ] : []), ]); diff --git a/src/app/(modules)/ticket/queries/ticketKeys.ts b/src/app/(modules)/ticket/queries/ticketKeys.ts index b288657..c0e1715 100644 --- a/src/app/(modules)/ticket/queries/ticketKeys.ts +++ b/src/app/(modules)/ticket/queries/ticketKeys.ts @@ -1,8 +1,4 @@ -import type { - DetectionCoordinatesParams, - TicketListParams, - VideoDetectionsParams, -} from '@/types'; +import type { TicketListParams, VideoDetectionsParams } from '@/types'; export const ticketKeys = { all: ['tickets'] as const, @@ -17,15 +13,6 @@ export const ticketKeys = { [...ticketKeys.details(), 'video', videoId, 'detections'] as const, classDetections: (videoId: string, params: VideoDetectionsParams) => [...ticketKeys.classDetectionLists(videoId), params] as const, - detectionCoordinateLists: (videoId: string) => - [ - ...ticketKeys.details(), - 'video', - videoId, - 'detection-coordinates', - ] as const, - detectionCoordinates: (videoId: string, params: DetectionCoordinatesParams) => - [...ticketKeys.detectionCoordinateLists(videoId), params] as const, reviewDetections: (videoId: string, proofStatus?: string) => [ ...ticketKeys.classDetectionLists(videoId), diff --git a/src/app/globals.css b/src/app/globals.css index a85fb0b..1562762 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,8 +1,8 @@ @import 'tailwindcss'; @import 'tw-animate-css'; @import './typography.css'; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; @custom-variant dark (&:is(.dark *)); @@ -152,7 +152,8 @@ html { @apply font-sans; } - button:not(:disabled), [role="button"]:not(:disabled) { + button:not(:disabled), + [role='button']:not(:disabled) { cursor: pointer; } } @@ -164,4 +165,19 @@ .detection-video-player .vds-slider-preview:has(.vds-slider-thumbnail img[src^='blob:']) { display: flex; -} \ No newline at end of file +} + +.leaflet-tooltip.range-point-label { + border: 0; + background: transparent; + box-shadow: none; + padding: 0; + font-weight: 700; + text-shadow: + 0 1px 2px rgb(255 255 255 / 95%), + 0 0 4px rgb(255 255 255 / 95%); +} + +.leaflet-tooltip.range-point-label::before { + display: none; +} diff --git a/src/constants/apiRoutes.ts b/src/constants/apiRoutes.ts index 639a5fe..c2f1685 100644 --- a/src/constants/apiRoutes.ts +++ b/src/constants/apiRoutes.ts @@ -57,7 +57,6 @@ export const API_ROUTES = { ANNOTATION_FRAMES: (id: string) => `/biz/api/v1/results/${id}/annotation-frames`, DETECTIONS: (id: string) => `/biz/api/v1/results/${id}/detections`, - COORDINATES: (id: string) => `/biz/api/v1/results/${id}/coordinates`, }, DETECTIONS: { DISCARD: (id: number) => `/biz/api/v1/detections/${id}/discard`, diff --git a/src/services/api/detection.service.ts b/src/services/api/detection.service.ts index 8c66e67..e2b1b9c 100644 --- a/src/services/api/detection.service.ts +++ b/src/services/api/detection.service.ts @@ -1,7 +1,5 @@ import { API_ROUTES } from '@/constants/apiRoutes'; import type { - DetectionCoordinatesParams, - DetectionCoordinatesResponse, DiscardDetectionPayload, DiscardDetectionResponse, ReviewDetectionRepairProofPayload, @@ -11,36 +9,6 @@ import type { import axiosClient from '../axios/axios'; export const detectionService = { - getCoordinates: async ( - videoId: string, - params?: DetectionCoordinatesParams, - ): Promise => { - const searchParams = new URLSearchParams(); - - params?.class_name?.forEach((className) => { - searchParams.append('class_name', className); - }); - if (params?.skip !== undefined) { - searchParams.set('skip', String(params.skip)); - } - if (params?.limit !== undefined) { - searchParams.set('limit', String(params.limit)); - } - if (params?.assignment_status) { - searchParams.set('assignment_status', params.assignment_status); - } - if (params?.search) { - searchParams.set('search', params.search); - } - - const response = await axiosClient.get( - API_ROUTES.VIDEOS.COORDINATES(videoId), - { params: searchParams }, - ); - - return response.data; - }, - discardDetection: async ( detectionId: number, payload: DiscardDetectionPayload, diff --git a/src/types/detection.ts b/src/types/detection.ts index 3784b1b..aef1644 100644 --- a/src/types/detection.ts +++ b/src/types/detection.ts @@ -5,38 +5,6 @@ export type DetectionClassCount = { unique_count: number; }; -export type DetectionCoordinateClass = { - class_name: string; - display_name: string; - count: number; -}; - -export type DetectionCoordinateItem = { - id: number; - class_name: string; - latitude: number; - longitude: number; - confidence: number; -}; - -export type DetectionAssignmentStatus = 'assigned' | 'unassigned'; - -export type DetectionCoordinatesParams = { - skip?: number; - limit?: number; - class_name?: string[]; - assignment_status?: DetectionAssignmentStatus; - search?: string; -}; - -export type DetectionCoordinatesResponse = { - total: number; - limit: number; - truncated: boolean; - classes: DetectionCoordinateClass[]; - items: DetectionCoordinateItem[]; -}; - export type DetectionBoundingBox = { x1: number; y1: number;