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;