refactor: remove static routes and stop multiple token api call in sse
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
||||
|
||||
interface ChainageData {
|
||||
name: string;
|
||||
defected_sign_board: number;
|
||||
pothole: number;
|
||||
road_crack: number;
|
||||
damaged_road_marking: number;
|
||||
drain_issue: number;
|
||||
defective_culvert: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ChainageBarChartProps {
|
||||
data: ChainageData[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
pothole: {
|
||||
label: DETECTION_TYPES.pothole.label + 's',
|
||||
color: DETECTION_TYPES.pothole.color,
|
||||
},
|
||||
defected_sign_board: {
|
||||
label: DETECTION_TYPES.defected_sign_board.label + 's',
|
||||
color: DETECTION_TYPES.defected_sign_board.color,
|
||||
},
|
||||
road_crack: {
|
||||
label: DETECTION_TYPES.road_crack.label + 's',
|
||||
color: DETECTION_TYPES.road_crack.color,
|
||||
},
|
||||
damaged_road_marking: {
|
||||
label: DETECTION_TYPES.damaged_road_marking.label + 's',
|
||||
color: DETECTION_TYPES.damaged_road_marking.color,
|
||||
},
|
||||
drain_issue: {
|
||||
label: DETECTION_TYPES.drain_issue.label + 's',
|
||||
color: DETECTION_TYPES.drain_issue.color,
|
||||
},
|
||||
defective_culvert: {
|
||||
label: DETECTION_TYPES.defective_culvert.label + 's',
|
||||
color: DETECTION_TYPES.defective_culvert.color,
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function ChainageBarChart({
|
||||
data,
|
||||
isLoading = false,
|
||||
}: ChainageBarChartProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-primary/50 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No segment data available</p>
|
||||
<p className="text-xs mt-1">
|
||||
Process videos to see detections by segment
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out good signboards for a more "damage-focused" standard chart as per multiple bar example
|
||||
const chartData = data.map((item) => ({
|
||||
name: item.name,
|
||||
pothole: item.pothole,
|
||||
defected_sign_board: item.defected_sign_board,
|
||||
road_crack: item.road_crack,
|
||||
damaged_road_marking: item.damaged_road_marking,
|
||||
drain_issue: item.drain_issue,
|
||||
defective_culvert: item.defective_culvert,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-[250px] w-full">
|
||||
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<CartesianGrid vertical={false} strokeOpacity={0.1} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) =>
|
||||
value.length > 8 ? `${value.slice(0, 8)}...` : value
|
||||
}
|
||||
fontSize={12}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dashed" />}
|
||||
/>
|
||||
<Bar dataKey="pothole" fill="var(--color-pothole)" radius={4} />
|
||||
<Bar
|
||||
dataKey="defected_sign_board"
|
||||
fill="var(--color-defected_sign_board)"
|
||||
radius={4}
|
||||
/>
|
||||
<Bar dataKey="road_crack" fill="var(--color-road_crack)" radius={4} />
|
||||
<Bar
|
||||
dataKey="damaged_road_marking"
|
||||
fill="var(--color-damaged_road_marking)"
|
||||
radius={4}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="drain_issue"
|
||||
fill="var(--color-drain_issue)"
|
||||
radius={4}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="defective_culvert"
|
||||
fill="var(--color-defective_culvert)"
|
||||
radius={4}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
CircleMarker,
|
||||
Popup,
|
||||
useMap,
|
||||
} from 'react-leaflet';
|
||||
import { LatLngBounds, LatLng } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { Detection } from '@/types';
|
||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
||||
|
||||
interface DashboardMapContentProps {
|
||||
detections: Detection[];
|
||||
}
|
||||
|
||||
// Component to auto-fit map bounds to show all markers
|
||||
function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
}, [bounds, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function DashboardMapContent({
|
||||
detections,
|
||||
}: DashboardMapContentProps) {
|
||||
if (detections.length === 0) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/20">
|
||||
<p className="text-muted-foreground">No detections to display</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate bounds to fit all markers
|
||||
const firstDetection = detections[0];
|
||||
const bounds = new LatLngBounds(
|
||||
new LatLng(firstDetection.latitude!, firstDetection.longitude!),
|
||||
new LatLng(firstDetection.latitude!, firstDetection.longitude!),
|
||||
);
|
||||
|
||||
detections.forEach((d) => {
|
||||
if (d.latitude && d.longitude) {
|
||||
bounds.extend(new LatLng(d.latitude, d.longitude));
|
||||
}
|
||||
});
|
||||
|
||||
// Center point
|
||||
const center: [number, number] = [
|
||||
(bounds.getNorth() + bounds.getSouth()) / 2,
|
||||
(bounds.getEast() + bounds.getWest()) / 2,
|
||||
];
|
||||
|
||||
// Get marker color based on detection type
|
||||
const getMarkerColor = (typeId: string) => {
|
||||
const config = DETECTION_TYPES[typeId.toLowerCase()];
|
||||
if (config) {
|
||||
return {
|
||||
fill: config.color,
|
||||
stroke: '#ffffff', // White stroke for better visibility on the map
|
||||
};
|
||||
}
|
||||
return { fill: '#64748b', stroke: '#475569' }; // Default Slate
|
||||
};
|
||||
|
||||
// Get display name for detection type
|
||||
const getTypeName = (typeId: string) => {
|
||||
const config = DETECTION_TYPES[typeId.toLowerCase()];
|
||||
return config ? config.label : typeId.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={13}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom={true}
|
||||
zoomAnimation={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Detection markers */}
|
||||
{detections.map((detection, idx) => {
|
||||
const colors = getMarkerColor(detection.type);
|
||||
const typeName = getTypeName(detection.type);
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`${detection.id}-${idx}`}
|
||||
center={[detection.latitude!, detection.longitude!]}
|
||||
radius={10}
|
||||
fillColor={colors.fill}
|
||||
color={colors.stroke}
|
||||
weight={2}
|
||||
opacity={1}
|
||||
fillOpacity={0.8}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm min-w-[200px] py-1">
|
||||
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none">
|
||||
{typeName}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<span className="text-muted-foreground text-xs">Class</span>
|
||||
<span className="font-medium text-right capitalize">
|
||||
{detection.class.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Confidence
|
||||
</span>
|
||||
<span className="font-medium text-right">
|
||||
{(detection.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
|
||||
Location Details
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||
Latitude
|
||||
</span>
|
||||
<span className="font-medium tracking-tighter">
|
||||
{detection.latitude!.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||
Longitude
|
||||
</span>
|
||||
<span className="font-medium tracking-tighter">
|
||||
{detection.longitude!.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Auto-fit bounds */}
|
||||
<FitBounds bounds={bounds} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, Milestone, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Detection } from '@/types';
|
||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
||||
|
||||
// Dynamically import the map to avoid SSR issues with Leaflet
|
||||
const DashboardMapContent = dynamic(() => import('./dashboard-map-content'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface DashboardMapProps {
|
||||
className?: string;
|
||||
selectedProjectId?: string | null;
|
||||
selectedPackageId?: string | null;
|
||||
selectedChainageId?: string | null;
|
||||
projectSummary?: any;
|
||||
}
|
||||
|
||||
export function DashboardMap({
|
||||
className,
|
||||
selectedProjectId,
|
||||
selectedPackageId,
|
||||
selectedChainageId,
|
||||
projectSummary,
|
||||
}: DashboardMapProps) {
|
||||
const [detections, setDetections] = useState<Detection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectSummary) {
|
||||
setDetections([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const filteredDetections: Detection[] = [];
|
||||
|
||||
const packagesToProcess =
|
||||
selectedPackageId && selectedPackageId !== 'all'
|
||||
? { [selectedPackageId]: projectSummary.packages[selectedPackageId] }
|
||||
: projectSummary.packages || {};
|
||||
|
||||
for (const [pkgName, pkg] of Object.entries(packagesToProcess)) {
|
||||
if (!pkg) continue;
|
||||
|
||||
const chainagesToProcess =
|
||||
selectedChainageId && selectedChainageId !== 'all'
|
||||
? {
|
||||
[selectedChainageId]: (pkg as any).chainages?.[
|
||||
selectedChainageId
|
||||
],
|
||||
}
|
||||
: (pkg as any).chainages || {};
|
||||
|
||||
for (const [chnName, chn] of Object.entries(chainagesToProcess)) {
|
||||
if (!chn) continue;
|
||||
const chainageDetections = ((chn as any).detections || []).filter(
|
||||
(d: any) =>
|
||||
(d.type || d.class || '').toLowerCase() !== 'good_sign_board',
|
||||
);
|
||||
filteredDetections.push(...chainageDetections);
|
||||
}
|
||||
}
|
||||
|
||||
setDetections(filteredDetections);
|
||||
} catch (err) {
|
||||
console.error('Failed to extract detections:', err);
|
||||
setError('Failed to load detection data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectSummary, selectedPackageId, selectedChainageId]);
|
||||
|
||||
// Handle detection type toggle
|
||||
const toggleType = (typeId: string) => {
|
||||
setSelectedTypes((prev) => {
|
||||
if (prev.includes(typeId)) {
|
||||
const next = prev.filter((id) => id !== typeId);
|
||||
return next.length === 0 ? [] : next;
|
||||
} else {
|
||||
return [...prev, typeId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Filter detections with valid GPS coordinates and selected types
|
||||
const validDetections = detections.filter((d) => {
|
||||
const hasGps = d.latitude && d.longitude;
|
||||
if (!hasGps) return false;
|
||||
|
||||
if (selectedTypes.length === 0) return true;
|
||||
const typeId = (d.type || d.class || '').toLowerCase();
|
||||
return selectedTypes.includes(typeId);
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`overflow-hidden transition-all duration-300 p-2 rounded-lg ${
|
||||
isMaximized
|
||||
? 'fixed inset-0 z-100 m-0 rounded-none bg-background'
|
||||
: className
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-0 relative rounded-lg">
|
||||
{/* Top Right Actions */}
|
||||
<div className="absolute top-4 right-4 z-1000 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
title={isMaximized ? 'Exit Fullscreen' : 'Maximize Map'}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Left Legend */}
|
||||
<div className="absolute bottom-4 left-4 z-1000 pointer-events-none">
|
||||
<div className="pointer-events-auto bg-zinc-950/80 backdrop-blur-xl border rounded-md p-2">
|
||||
<div className="flex flex-wrap items-center gap-2 max-w-4xl">
|
||||
{Object.values(DETECTION_TYPES).map((type) => {
|
||||
const typeId = type.id.toLowerCase();
|
||||
const count = detections.filter(
|
||||
(d) =>
|
||||
(d.type || d.class || '').toLowerCase() === typeId &&
|
||||
d.latitude &&
|
||||
d.longitude,
|
||||
).length;
|
||||
|
||||
if (
|
||||
count === 0 &&
|
||||
(type.id.includes('culvert') || type.id === 'drain_issue')
|
||||
)
|
||||
return null;
|
||||
if (type.id === 'good_sign_board') return null;
|
||||
|
||||
const isSelected =
|
||||
selectedTypes.length === 0 || selectedTypes.includes(typeId);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => toggleType(typeId)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-zinc-100 rounded-full transition-all text-xs ${
|
||||
isSelected ? 'bg-zinc-800/80 ' : 'bg-transparent '
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm"
|
||||
style={{ backgroundColor: type.color }}
|
||||
/>
|
||||
<span>
|
||||
{type.label} ({count})
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${isMaximized ? 'h-screen' : 'h-[600px]'} w-full relative transition-all`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : validDetections.length === 0 ? (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/50">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No detections with GPS coordinates found
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 opacity-70">
|
||||
Process some videos to see detections on the map
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DashboardMapContent detections={validDetections} />
|
||||
)}
|
||||
|
||||
{/* Bottom Left Label Overlay */}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="py-6 px-4">
|
||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<Skeleton className="h-4 w-24 mb-1" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-9 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-12 h-12 rounded-xl shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts Row Skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
{/* Donut Chart Skeleton */}
|
||||
<Card className="h-full">
|
||||
<CardHeader className="items-center pb-2">
|
||||
<Skeleton className="h-6 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px] flex items-center justify-center">
|
||||
<Skeleton className="h-56 w-56 rounded-full border-20 border-muted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bar Chart Skeleton */}
|
||||
<Card className="h-full">
|
||||
<CardHeader className="items-center pb-2">
|
||||
<Skeleton className="h-6 w-40 mb-2" />
|
||||
<Skeleton className="h-4 w-52" />
|
||||
</CardHeader>
|
||||
<CardContent className="h-[300px] flex items-end justify-between gap-2 px-6 pb-8 pt-4">
|
||||
{[...Array(8)].map((_, i) => {
|
||||
const heights = [
|
||||
'60%',
|
||||
'40%',
|
||||
'75%',
|
||||
'50%',
|
||||
'65%',
|
||||
'35%',
|
||||
'80%',
|
||||
'45%',
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 items-center flex-1"
|
||||
>
|
||||
<Skeleton
|
||||
className="w-full rounded-t-sm"
|
||||
style={{ height: heights[i % heights.length] }}
|
||||
/>
|
||||
<Skeleton className="h-2 w-full mt-2" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Map Skeleton */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="w-9 h-9 rounded-md" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<Skeleton className="w-2.5 h-2.5 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2">
|
||||
<Skeleton className="h-[500px] w-full rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Label, Pie, PieChart } from 'recharts';
|
||||
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import { DETECTION_TYPES } from '@/constants/detectionModeConfig';
|
||||
|
||||
interface DetectionDonutChartProps {
|
||||
defectedSignboard: number;
|
||||
pothole: number;
|
||||
roadCrack: number;
|
||||
damagedRoadMarking: number;
|
||||
// goodSignboard: number;
|
||||
drainIssue: number;
|
||||
defectiveCulvert: number;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
pothole: {
|
||||
label: DETECTION_TYPES.pothole.label + 's',
|
||||
color: DETECTION_TYPES.pothole.color,
|
||||
},
|
||||
defectedSignboard: {
|
||||
label: DETECTION_TYPES.defected_sign_board.label + 's',
|
||||
color: DETECTION_TYPES.defected_sign_board.color,
|
||||
},
|
||||
roadCrack: {
|
||||
label: DETECTION_TYPES.road_crack.label + 's',
|
||||
color: DETECTION_TYPES.road_crack.color,
|
||||
},
|
||||
damagedRoadMarking: {
|
||||
label: DETECTION_TYPES.damaged_road_marking.label + 's',
|
||||
color: DETECTION_TYPES.damaged_road_marking.color,
|
||||
},
|
||||
drainIssue: {
|
||||
label: DETECTION_TYPES.drain_issue.label + 's',
|
||||
color: DETECTION_TYPES.drain_issue.color,
|
||||
},
|
||||
defectiveCulvert: {
|
||||
label: DETECTION_TYPES.defective_culvert.label + 's',
|
||||
color: DETECTION_TYPES.defective_culvert.color,
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function DetectionDonutChart({
|
||||
defectedSignboard,
|
||||
pothole,
|
||||
roadCrack,
|
||||
damagedRoadMarking,
|
||||
// goodSignboard,
|
||||
drainIssue,
|
||||
defectiveCulvert,
|
||||
isLoading = false,
|
||||
}: DetectionDonutChartProps) {
|
||||
const chartData = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{ type: 'pothole', count: pothole, fill: 'var(--color-pothole)' },
|
||||
{
|
||||
type: 'defectedSignboard',
|
||||
count: defectedSignboard,
|
||||
fill: 'var(--color-defectedSignboard)',
|
||||
},
|
||||
{ type: 'roadCrack', count: roadCrack, fill: 'var(--color-roadCrack)' },
|
||||
{
|
||||
type: 'damagedRoadMarking',
|
||||
count: damagedRoadMarking,
|
||||
fill: 'var(--color-damagedRoadMarking)',
|
||||
},
|
||||
/* {
|
||||
type: 'goodSignboard',
|
||||
count: goodSignboard,
|
||||
fill: 'var(--color-goodSignboard)',
|
||||
}, */
|
||||
{
|
||||
type: 'drainIssue',
|
||||
count: drainIssue,
|
||||
fill: 'var(--color-drainIssue)',
|
||||
},
|
||||
{
|
||||
type: 'defectiveCulvert',
|
||||
count: defectiveCulvert,
|
||||
fill: 'var(--color-defectiveCulvert)',
|
||||
},
|
||||
].filter((item) => item.count > 0),
|
||||
[
|
||||
pothole,
|
||||
defectedSignboard,
|
||||
roadCrack,
|
||||
damagedRoadMarking,
|
||||
// goodSignboard,
|
||||
drainIssue,
|
||||
defectiveCulvert,
|
||||
],
|
||||
);
|
||||
|
||||
const totalDetections = React.useMemo(() => {
|
||||
return chartData.reduce((acc, curr) => acc + curr.count, 0);
|
||||
}, [chartData]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-[250px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 text-primary/50 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalDetections === 0) {
|
||||
return (
|
||||
<div className="h-[250px] flex flex-col items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No detections found</p>
|
||||
<p className="text-xs mt-1">Process videos to see data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent hideLabel />}
|
||||
/>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="count"
|
||||
nameKey="type"
|
||||
innerRadius={60}
|
||||
strokeWidth={5}
|
||||
>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="fill-foreground text-3xl font-bold"
|
||||
>
|
||||
{totalDetections.toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
Total
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { FolderOpen, Package, Milestone } from 'lucide-react';
|
||||
import { Project } from '@/types';
|
||||
|
||||
interface FilterSelectorProps {
|
||||
projects: Project[];
|
||||
selectedProjectId: string | null;
|
||||
selectedPackageId: string | null;
|
||||
selectedChainageId: string | null;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
onPackageChange: (packageId: string) => void;
|
||||
onChainageChange: (chainageId: string) => void;
|
||||
packages: Array<{ id: string; name: string }>;
|
||||
chainages: Array<{ id: string; name: string }>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function FilterSelector({
|
||||
projects,
|
||||
selectedProjectId,
|
||||
selectedPackageId,
|
||||
selectedChainageId,
|
||||
onProjectChange,
|
||||
onPackageChange,
|
||||
onChainageChange,
|
||||
packages,
|
||||
chainages,
|
||||
isLoading = false,
|
||||
}: FilterSelectorProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-5">
|
||||
{/* Project Dropdown */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">
|
||||
Project
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedProjectId || ''}
|
||||
onValueChange={onProjectChange}
|
||||
disabled={isLoading || projects.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Package Dropdown */}
|
||||
{selectedProjectId && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
|
||||
<Package className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">
|
||||
Package
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedPackageId || 'all'}
|
||||
onValueChange={onPackageChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
|
||||
<SelectValue placeholder="All packages" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Packages</SelectItem>
|
||||
{packages.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id}>
|
||||
{pkg.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chainage Dropdown */}
|
||||
{selectedPackageId && selectedPackageId !== 'all' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-lg bg-gray-900 dark:bg-gray-100 text-gray-100 dark:text-gray-900 shadow-sm">
|
||||
<Milestone className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-[11px] font-black text-muted-foreground uppercase tracking-[0.15em]">
|
||||
Segment
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedChainageId || 'all'}
|
||||
onValueChange={onChainageChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="h-10 text-sm w-full bg-background border-border/60">
|
||||
<SelectValue placeholder="All segments" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Segments</SelectItem>
|
||||
{chainages.map((chn) => (
|
||||
<SelectItem key={chn.id} value={chn.id}>
|
||||
{chn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function StatsCard({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
icon: Icon,
|
||||
isLoading = false,
|
||||
}: StatsCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ y: -4, scale: 1.02 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full py-6 px-4 transition-colors cursor-pointer">
|
||||
<CardContent className="p-0 flex items-center justify-between gap-6">
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-24 bg-muted rounded-md mt-2 animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-3xl font-bold tracking-tight">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-xl bg-primary/10 text-primary shrink-0 group-hover:bg-primary group-hover:text-primary-foreground transition-colors">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
MapContainer,
|
||||
TileLayer,
|
||||
Polyline,
|
||||
CircleMarker,
|
||||
Popup,
|
||||
useMap,
|
||||
} from 'react-leaflet';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { LatLngBounds, LatLng } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import {
|
||||
getDetectionModeConfig,
|
||||
DETECTION_TYPES,
|
||||
} from '@/constants/detectionModeConfig';
|
||||
import { DetectionType } from '@/types';
|
||||
|
||||
type Detection = {
|
||||
id: number;
|
||||
type: string;
|
||||
class: string;
|
||||
confidence: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
frame_number: number;
|
||||
};
|
||||
|
||||
type MapModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
detections: Detection[];
|
||||
detectionType: DetectionType | string;
|
||||
};
|
||||
|
||||
// Component to auto-fit map bounds to show all markers
|
||||
function FitBounds({ bounds }: { bounds: LatLngBounds }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !bounds.isValid()) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
map.fitBounds(bounds, {
|
||||
padding: [50, 50],
|
||||
maxZoom: 16,
|
||||
animate: true,
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [bounds, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MapModal({
|
||||
open,
|
||||
onClose,
|
||||
detections,
|
||||
detectionType,
|
||||
}: MapModalProps) {
|
||||
const validDetections = detections.filter((d) => d.latitude && d.longitude);
|
||||
|
||||
if (validDetections.length === 0) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl h-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Detection Map</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm font-medium">
|
||||
No GPS data available for detections.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const routeCoordinates: [number, number][] = validDetections.map((d) => [
|
||||
d.latitude,
|
||||
d.longitude,
|
||||
]);
|
||||
const bounds = new LatLngBounds(
|
||||
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
|
||||
new LatLng(routeCoordinates[0][0], routeCoordinates[0][1]),
|
||||
);
|
||||
routeCoordinates.forEach((coord) =>
|
||||
bounds.extend(new LatLng(coord[0], coord[1])),
|
||||
);
|
||||
const center: [number, number] = [
|
||||
(bounds.getNorth() + bounds.getSouth()) / 2,
|
||||
(bounds.getEast() + bounds.getWest()) / 2,
|
||||
];
|
||||
|
||||
const modeConfig = getDetectionModeConfig(detectionType);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-6xl h-[85vh] p-0 flex flex-col overflow-hidden border-none shadow-2xl">
|
||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
||||
<DialogTitle className="text-xl font-bold">
|
||||
{modeConfig.label} Map
|
||||
</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
{validDetections.length} points of interest identified with GPS data
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 w-full relative bg-muted/20">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={13}
|
||||
className="h-full w-full"
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<Polyline
|
||||
positions={routeCoordinates}
|
||||
color="#3b82f6"
|
||||
weight={4}
|
||||
opacity={0.6}
|
||||
/>
|
||||
|
||||
{validDetections.map((detection, idx) => {
|
||||
const type = (detection.type || '').toLowerCase();
|
||||
const typeConfig = DETECTION_TYPES[type];
|
||||
const color = typeConfig
|
||||
? { fill: typeConfig.color, stroke: typeConfig.color }
|
||||
: { fill: '#64748b', stroke: '#475569' };
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={`${detection.id}-${idx}`}
|
||||
center={[detection.latitude, detection.longitude]}
|
||||
radius={7}
|
||||
fillColor={color.fill}
|
||||
color={color.stroke}
|
||||
weight={2}
|
||||
opacity={1}
|
||||
fillOpacity={0.9}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm min-w-[200px] py-1">
|
||||
<div className="font-bold text-base border-b border-border pb-2 mb-3 leading-none capitalize">
|
||||
{(detection.type || '').replace(/_/g, ' ')}{' '}
|
||||
<span className="text-muted-foreground font-medium text-sm ml-1">
|
||||
#{detection.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Frame
|
||||
</span>
|
||||
<span className="font-medium text-right font-mono">
|
||||
{detection.frame_number}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-baseline gap-4">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Confidence
|
||||
</span>
|
||||
<span className="font-medium text-right">
|
||||
{(detection.confidence * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<div className="text-[10px] uppercase tracking-wider font-semibold text-muted-foreground mb-2">
|
||||
Location Details
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 bg-muted/40 p-2.5 rounded-md font-mono text-[11px]">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||
Latitude
|
||||
</span>
|
||||
<span className="font-medium tracking-tighter">
|
||||
{detection.latitude.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[9px] block text-muted-foreground/70 uppercase">
|
||||
Longitude
|
||||
</span>
|
||||
<span className="font-medium tracking-tighter">
|
||||
{detection.longitude.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
<FitBounds bounds={bounds} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user