refactor: update crime-related interfaces for consistency and clarity across components

This commit is contained in:
vergiLgood1 2025-05-05 03:11:07 +07:00
parent 2aa2e609f1
commit a2f51e6837
3 changed files with 117 additions and 137 deletions

View File

@ -13,27 +13,29 @@ import { ITooltips } from "./tooltips"
import { $Enums } from "@prisma/client"
// Define types based on the crime data structure
interface CrimeIncident {
interface ICrimeIncident {
id: string
timestamp: Date
description: string
status: string
latitude?: number
longitude?: number
address?: string
crime_categories?: {
locations: {
address: string;
longitude: number;
latitude: number;
},
crime_categories: {
id: string
name: string
}
}
interface Crime {
interface ICrime {
id: string
district_id: string
month: number
year: number
crime_incidents: CrimeIncident[]
district: {
crime_incidents: ICrimeIncident[]
districts: {
name: string
}
}
@ -71,21 +73,21 @@ const ACTIONS = [
placeholder: "Enter crime description",
},
{
id: "address",
label: "Search by Address",
id: "locations.address",
label: "Search by locations.Address",
icon: <FolderOpen className="h-4 w-4 text-amber-500" />,
description: "e.g., Jalan Sudirman",
category: "Search",
prefix: "",
regex: /.+/,
placeholder: "Enter location or address",
placeholder: "Enter location or locations.address",
},
]
interface SearchTooltipProps {
onControlChange?: (controlId: ITooltips) => void
activeControl?: string
crimes?: Crime[]
crimes?: ICrime[]
}
export default function SearchTooltip({ onControlChange, activeControl, crimes = [] }: SearchTooltipProps) {
@ -93,9 +95,9 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
const searchInputRef = useRef<HTMLInputElement>(null)
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
const [searchValue, setSearchValue] = useState("")
const [suggestions, setSuggestions] = useState<CrimeIncident[]>([])
const [suggestions, setSuggestions] = useState<ICrimeIncident[]>([])
const [isInputValid, setIsInputValid] = useState(true)
const [selectedSuggestion, setSelectedSuggestion] = useState<CrimeIncident | null>(null)
const [selectedSuggestion, setSelectedSuggestion] = useState<ICrimeIncident | null>(null)
const [showInfoBox, setShowInfoBox] = useState(false)
// Limit results to prevent performance issues
@ -105,7 +107,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
const allIncidents = crimes.flatMap(crime =>
crime.crime_incidents.map(incident => ({
...incident,
district: crime.district?.name || '',
district: crime.districts?.name || '',
year: crime.year,
month: crime.month
}))
@ -129,11 +131,11 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
setIsInputValid(true);
// Initial suggestions based on the selected search type
let initialSuggestions: CrimeIncident[] = [];
let initialSuggestions: ICrimeIncident[] = [];
if (actionId === 'incident_id') {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS); // Limit to 50 results initially
} else if (actionId === 'description' || actionId === 'address') {
} else if (actionId === 'description' || actionId === 'locations.address') {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS);
}
@ -154,8 +156,8 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
}
// Filter suggestions based on search type and search text
const filterSuggestions = (searchType: string, searchText: string): CrimeIncident[] => {
let filtered: CrimeIncident[] = [];
const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => {
let filtered: ICrimeIncident[] = [];
if (searchType === 'incident_id') {
if (!searchText || searchText === 'CI-') {
@ -175,26 +177,26 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
).slice(0, MAX_RESULTS);
}
}
else if (searchType === 'address') {
else if (searchType === 'locations.address') {
if (!searchText) {
filtered = allIncidents.slice(0, MAX_RESULTS);
} else {
filtered = allIncidents.filter(item =>
item.address && item.address.toLowerCase().includes(searchText.toLowerCase())
item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase())
).slice(0, MAX_RESULTS);
}
}
else if (searchType === 'coordinates') {
if (!searchText) {
filtered = allIncidents.filter(item => item.latitude !== undefined && item.longitude !== undefined)
filtered = allIncidents.filter(item => item.locations.latitude !== undefined && item.locations.longitude !== undefined)
.slice(0, MAX_RESULTS);
} else {
// For coordinates, we'd typically do a proximity search
// This is a simple implementation for demo purposes
filtered = allIncidents.filter(item =>
item.latitude !== undefined &&
item.longitude !== undefined &&
`${item.latitude}, ${item.longitude}`.includes(searchText)
item.locations.latitude !== undefined &&
item.locations.longitude !== undefined &&
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText)
).slice(0, MAX_RESULTS);
}
}
@ -244,7 +246,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
}
};
const handleSuggestionSelect = (incident: CrimeIncident) => {
const handleSuggestionSelect = (incident: ICrimeIncident) => {
setSearchValue(incident.id);
setSuggestions([]);
setSelectedSuggestion(incident);
@ -252,12 +254,12 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
};
const handleFlyToIncident = () => {
if (!selectedSuggestion || !selectedSuggestion.latitude || !selectedSuggestion.longitude) return;
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return;
const flyToEvent = new CustomEvent('fly_to_incident', {
const flyToEvent = new CustomEvent('incident_click', {
detail: {
longitude: selectedSuggestion.longitude,
latitude: selectedSuggestion.latitude,
longitude: selectedSuggestion.locations.longitude,
latitude: selectedSuggestion.locations.latitude,
id: selectedSuggestion.id,
zoom: 15,
description: selectedSuggestion.description,
@ -294,7 +296,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
}
// Format date for display
const formatIncidentDate = (incident: CrimeIncident) => {
const formatIncidentDate = (incident: ICrimeIncident) => {
try {
if (incident.timestamp) {
return format(new Date(incident.timestamp), 'PPP p');
@ -410,11 +412,11 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
</span>
) : selectedSearchType === 'coordinates' ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.latitude}, {incident.longitude} - {incident.description}
{incident.locations.latitude}, {incident.locations.longitude} - {incident.description}
</span>
) : selectedSearchType === 'address' ? (
) : selectedSearchType === 'locations.address' ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.address || 'N/A'}
{incident.locations.address || 'N/A'}
</span>
) : (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
@ -485,10 +487,10 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
</div>
)}
{selectedSuggestion.address && (
{selectedSuggestion.locations.address && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<MapPin className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.address}</p>
<p className="text-sm">{selectedSuggestion.locations.address}</p>
</div>
)}
@ -515,7 +517,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
variant="default"
size="sm"
onClick={handleFlyToIncident}
disabled={!selectedSuggestion.latitude || !selectedSuggestion.longitude}
disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude}
className="flex items-center gap-2"
>
<span>Fly to Incident</span>

View File

@ -23,14 +23,15 @@ import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
import Tooltips from "./controls/top/tooltips"
// Updated CrimeIncident type to match the structure in crime_incidents
interface CrimeIncident {
interface ICrimeIncident {
id: string
timestamp: Date
description: string
status: string
district?: string
category?: string
type?: string
address?: string
type_category?: string | null
description?: string
status: string
address?: string | null
timestamp?: Date
latitude?: number
longitude?: number
}
@ -39,7 +40,7 @@ export default function CrimeMap() {
// State for sidebar
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true)
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [selectedYear, setSelectedYear] = useState<number>(2024)
@ -113,22 +114,55 @@ export default function CrimeMap() {
useEffect(() => {
const handleIncidentClickEvent = (e: CustomEvent) => {
console.log("Received incident_click event:", e.detail);
if (e.detail) {
if (!e.detail.longitude || !e.detail.latitude) {
console.error("Invalid incident coordinates in event:", e.detail);
return;
}
// When an incident is clicked, clear any selected district
setSelectedDistrict(null);
// Set the selected incident
setSelectedIncident(e.detail);
if (!e.detail || !e.detail.id) {
console.error("Invalid incident data in event:", e.detail);
return;
}
}
// Find the incident in filtered crimes data using the id from the event
let foundIncident: ICrimeIncident | undefined;
// Search through all crimes and their incidents to find matching incident
filteredCrimes.forEach(crime => {
crime.crime_incidents.forEach(incident => {
if (incident.id === e.detail.id) {
// Map the found incident to ICrimeIncident type
foundIncident = {
id: incident.id,
district: crime.districts.name,
description: incident.description,
status: incident.status || "unknown",
timestamp: incident.timestamp,
category: incident.crime_categories.name,
type_category: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
};
}
});
});
if (!foundIncident) {
console.error("Could not find incident with ID:", e.detail.id);
return;
}
// Validate the coordinates
if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Invalid incident coordinates:", foundIncident);
return;
}
// When an incident is clicked, clear any selected district
setSelectedDistrict(null);
// Set the selected incident
setSelectedIncident(foundIncident);
};
// Add event listener to the map container and document
const mapContainer = mapContainerRef.current
const mapContainer = mapContainerRef.current;
// Clean up previous listeners to prevent duplicates
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
@ -145,67 +179,11 @@ export default function CrimeMap() {
return () => {
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) {
mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
}
}
}, []);
// Set up event listener for fly-to-incident events from search
useEffect(() => {
const handleFlyToIncident = (e: CustomEvent) => {
if (!e.detail || !e.detail.longitude || !e.detail.latitude) {
console.error("Invalid fly-to coordinates:", e.detail);
return;
}
// Handle the fly-to event by dispatching to the map
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
if (mapInstance) {
// Clear any existing selections first
setSelectedIncident(null);
setSelectedDistrict(null);
// Create an incident object to highlight
const incidentToHighlight: CrimeIncident = {
id: e.detail.id as string,
latitude: e.detail.latitude as number,
longitude: e.detail.longitude as number,
timestamp: new Date(),
description: e.detail.description || "",
status: e.detail.status
};
// First fly to the location
const flyEvent = new CustomEvent('mapbox_fly_to', {
detail: {
longitude: e.detail.longitude,
latitude: e.detail.latitude,
zoom: e.detail.zoom || 15,
bearing: 0,
pitch: 45,
duration: 2000,
},
bubbles: true
});
mapInstance.dispatchEvent(flyEvent);
// After flying, select the incident with a slight delay
setTimeout(() => {
setSelectedIncident(incidentToHighlight);
}, 2000);
}
}
// Add event listener
document.addEventListener('fly_to_incident', handleFlyToIncident as EventListener);
return () => {
document.removeEventListener('fly_to_incident', handleFlyToIncident as EventListener);
}
}, []);
};
}, [filteredCrimes]);
// Handle year-month timeline change
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
@ -310,7 +288,7 @@ export default function CrimeMap() {
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={() => setSelectedIncident(null)}
crime={selectedIncident}
incident={selectedIncident}
/>
</>

View File

@ -7,25 +7,25 @@ import { Separator } from "@/app/_components/ui/separator"
import { Button } from "@/app/_components/ui/button"
import { MapPin, AlertTriangle, Calendar, Clock, Tag, Bookmark, FileText, Navigation, X } from "lucide-react"
interface CrimePopupProps {
interface IncidentPopupProps {
longitude: number
latitude: number
onClose: () => void
crime: {
incident: {
id: string
district?: string
category?: string
type?: string
type_category?: string | null
description?: string
status?: string
address?: string
address?: string | null
timestamp?: Date
latitude?: number
longitude?: number
}
}
export default function CrimePopup({ longitude, latitude, onClose, crime }: CrimePopupProps) {
export default function IncidentPopup({ longitude, latitude, onClose, incident }: IncidentPopupProps) {
const formatDate = (date?: Date) => {
if (!date) return "Unknown date"
return new Date(date).toLocaleDateString()
@ -80,10 +80,10 @@ export default function CrimePopup({ longitude, latitude, onClose, crime }: Crim
onClose={onClose}
anchor="top"
maxWidth="320px"
className="crime-popup z-50"
className="incident-popup z-50"
>
<Card
className={`bg-background p-0 w-full max-w-[320px] shadow-xl border-0 overflow-hidden border-l-4 ${getBorderColor(crime.status)}`}
className={`bg-background p-0 w-full max-w-[320px] shadow-xl border-0 overflow-hidden border-l-4 ${getBorderColor(incident.status)}`}
>
<div className="p-4 relative">
{/* Custom close button */}
@ -100,16 +100,16 @@ export default function CrimePopup({ longitude, latitude, onClose, crime }: Crim
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-base flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 text-red-500" />
{crime.category || "Unknown Incident"}
{incident.category || "Unknown Incident"}
</h3>
{getStatusBadge(crime.status)}
{getStatusBadge(incident.status)}
</div>
{crime.description && (
{incident.description && (
<div className="mb-3 bg-slate-50 dark:bg-slate-900/40 p-3 rounded-lg">
<p className="text-sm">
<FileText className="inline-block h-3.5 w-3.5 mr-1.5 align-text-top text-slate-500" />
{crime.description}
{incident.description}
</p>
</div>
)}
@ -118,51 +118,51 @@ export default function CrimePopup({ longitude, latitude, onClose, crime }: Crim
{/* Improved section headers */}
<div className="grid grid-cols-2 gap-2 text-sm">
{crime.district && (
{incident.district && (
<div className="col-span-2">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">District</p>
<p className="flex items-center">
<Bookmark className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-purple-500" />
<span className="font-medium">{crime.district}</span>
<span className="font-medium">{incident.district}</span>
</p>
</div>
)}
{crime.address && (
{incident.address && (
<div className="col-span-2">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Location</p>
<p className="flex items-center">
<MapPin className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-red-500" />
<span className="font-medium">{crime.address}</span>
<span className="font-medium">{incident.address}</span>
</p>
</div>
)}
{crime.timestamp && (
{incident.timestamp && (
<>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Date</p>
<p className="flex items-center">
<Calendar className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-blue-500" />
<span className="font-medium">{formatDate(crime.timestamp)}</span>
<span className="font-medium">{formatDate(incident.timestamp)}</span>
</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Time</p>
<p className="flex items-center">
<Clock className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-amber-500" />
<span className="font-medium">{formatTime(crime.timestamp)}</span>
<span className="font-medium">{formatTime(incident.timestamp)}</span>
</p>
</div>
</>
)}
{crime.type && (
{incident.type_category && (
<div className="col-span-2">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Type</p>
<p className="flex items-center">
<Tag className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-green-500" />
<span className="font-medium">{crime.type}</span>
<span className="font-medium">{incident.type_category}</span>
</p>
</div>
)}
@ -173,7 +173,7 @@ export default function CrimePopup({ longitude, latitude, onClose, crime }: Crim
<Navigation className="inline-block h-3 w-3 mr-1 shrink-0" />
Coordinates: {latitude.toFixed(6)}, {longitude.toFixed(6)}
</p>
<p className="text-xs text-muted-foreground mt-1">ID: {crime.id}</p>
<p className="text-xs text-muted-foreground mt-1">ID: {incident.id}</p>
</div>
</div>
</Card>