add filter source_type
This commit is contained in:
parent
77c865958a
commit
2a8f249d0c
|
@ -4,6 +4,8 @@ import {
|
|||
getCrimeByYearAndMonth,
|
||||
getCrimeCategories,
|
||||
getCrimes,
|
||||
getCrimesTypes,
|
||||
getRecentIncidents,
|
||||
} from '../action';
|
||||
|
||||
export const useGetAvailableYears = () => {
|
||||
|
@ -36,3 +38,17 @@ export const useGetCrimeCategories = () => {
|
|||
queryFn: () => getCrimeCategories(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCrimeTypes = () => {
|
||||
return useQuery({
|
||||
queryKey: ['crime-types'],
|
||||
queryFn: () => getCrimesTypes(),
|
||||
});
|
||||
}
|
||||
|
||||
export const useGetRecentIncidents = () => {
|
||||
return useQuery({
|
||||
queryKey: ['recent-incidents'],
|
||||
queryFn: () => getRecentIncidents(),
|
||||
});
|
||||
}
|
|
@ -1,24 +1,26 @@
|
|||
'use server';
|
||||
"use server";
|
||||
|
||||
import { createClient } from '@/app/_utils/supabase/client';
|
||||
import { getSeverity } from "@/app/_utils/crime";
|
||||
import { createClient } from "@/app/_utils/supabase/client";
|
||||
|
||||
import {
|
||||
ICrimes,
|
||||
ICrimesByYearAndMonth,
|
||||
IDistanceResult,
|
||||
} from '@/app/_utils/types/crimes';
|
||||
import { getInjection } from '@/di/container';
|
||||
import db from '@/prisma/db';
|
||||
IIncidentLogs,
|
||||
} from "@/app/_utils/types/crimes";
|
||||
import { getInjection } from "@/di/container";
|
||||
import db from "@/prisma/db";
|
||||
import {
|
||||
AuthenticationError,
|
||||
UnauthenticatedError,
|
||||
} from '@/src/entities/errors/auth';
|
||||
import { InputParseError } from '@/src/entities/errors/common';
|
||||
} from "@/src/entities/errors/auth";
|
||||
import { InputParseError } from "@/src/entities/errors/common";
|
||||
|
||||
export async function getAvailableYears() {
|
||||
const instrumentationService = getInjection('IInstrumentationService');
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
'Available Years',
|
||||
"Available Years",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
try {
|
||||
|
@ -26,50 +28,38 @@ export async function getAvailableYears() {
|
|||
select: {
|
||||
year: true,
|
||||
},
|
||||
distinct: ['year'],
|
||||
distinct: ["year"],
|
||||
orderBy: {
|
||||
year: 'asc',
|
||||
year: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return years.map((year) => year.year);
|
||||
} catch (err) {
|
||||
if (err instanceof InputParseError) {
|
||||
// return {
|
||||
// error: err.message,
|
||||
// };
|
||||
|
||||
throw new InputParseError(err.message);
|
||||
}
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
// return {
|
||||
// error: 'User not found.',
|
||||
// };
|
||||
|
||||
throw new AuthenticationError(
|
||||
'There was an error with the credentials. Please try again or contact support.'
|
||||
"There was an error with the credentials. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const crashReporterService = getInjection('ICrashReporterService');
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(err);
|
||||
// return {
|
||||
// error:
|
||||
// 'An error happened. The developers have been notified. Please try again later.',
|
||||
// };
|
||||
throw new Error(
|
||||
'An error happened. The developers have been notified. Please try again later.'
|
||||
"An error happened. The developers have been notified. Please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCrimeCategories() {
|
||||
const instrumentationService = getInjection('IInstrumentationService');
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
'Crime Categories',
|
||||
"Crime Categories",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
try {
|
||||
|
@ -84,41 +74,29 @@ export async function getCrimeCategories() {
|
|||
return categories;
|
||||
} catch (err) {
|
||||
if (err instanceof InputParseError) {
|
||||
// return {
|
||||
// error: err.message,
|
||||
// };
|
||||
|
||||
throw new InputParseError(err.message);
|
||||
}
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
// return {
|
||||
// error: 'User not found.',
|
||||
// };
|
||||
|
||||
throw new AuthenticationError(
|
||||
'There was an error with the credentials. Please try again or contact support.'
|
||||
"There was an error with the credentials. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const crashReporterService = getInjection('ICrashReporterService');
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(err);
|
||||
// return {
|
||||
// error:
|
||||
// 'An error happened. The developers have been notified. Please try again later.',
|
||||
// };
|
||||
throw new Error(
|
||||
'An error happened. The developers have been notified. Please try again later.'
|
||||
"An error happened. The developers have been notified. Please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCrimes(): Promise<ICrimes[]> {
|
||||
const instrumentationService = getInjection('IInstrumentationService');
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
'District Crime Data',
|
||||
"District Crime Data",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
try {
|
||||
|
@ -173,54 +151,121 @@ export async function getCrimes(): Promise<ICrimes[]> {
|
|||
return crimes;
|
||||
} catch (err) {
|
||||
if (err instanceof InputParseError) {
|
||||
// return {
|
||||
// error: err.message,
|
||||
// };
|
||||
|
||||
throw new InputParseError(err.message);
|
||||
}
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
// return {
|
||||
// error: 'User not found.',
|
||||
// };
|
||||
|
||||
throw new AuthenticationError(
|
||||
'There was an error with the credentials. Please try again or contact support.'
|
||||
"There was an error with the credentials. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const crashReporterService = getInjection('ICrashReporterService');
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(err);
|
||||
// return {
|
||||
// error:
|
||||
// 'An error happened. The developers have been notified. Please try again later.',
|
||||
// };
|
||||
throw new Error(
|
||||
'An error happened. The developers have been notified. Please try again later.'
|
||||
"An error happened. The developers have been notified. Please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRecentIncidents(): Promise<IIncidentLogs[]> {
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
"Recent Incidents",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const incidents = await db.incident_logs.findMany({
|
||||
where: {
|
||||
time: {
|
||||
gte: yesterday,
|
||||
lte: now,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
time: "desc",
|
||||
},
|
||||
include: {
|
||||
crime_categories: {
|
||||
select: {
|
||||
name: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
locations: {
|
||||
select: {
|
||||
districts: {
|
||||
select: {
|
||||
name:true
|
||||
}
|
||||
},
|
||||
address: true,
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Map DB result to IIncidentLogs interface
|
||||
return incidents.map((incident) => ({
|
||||
id: incident.id,
|
||||
user_id: incident.user_id,
|
||||
latitude: incident.locations?.latitude ?? null,
|
||||
longitude: incident.locations?.longitude ?? null,
|
||||
district: incident.locations.districts.name ?? "",
|
||||
address: incident.locations?.address ?? "",
|
||||
category: incident.crime_categories?.name ?? "",
|
||||
source: incident.source ?? "",
|
||||
description: incident.description ?? "",
|
||||
verified: incident.verified ?? false,
|
||||
severity: getSeverity(incident.crime_categories.name),
|
||||
timestamp: incident.time,
|
||||
created_at: incident.created_at ?? incident.time ?? new Date(),
|
||||
updated_at: incident.updated_at ?? incident.time ?? new Date(),
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof InputParseError) {
|
||||
throw new InputParseError(err.message);
|
||||
}
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
throw new AuthenticationError(
|
||||
"There was an error with the credentials. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(err);
|
||||
throw new Error(
|
||||
"An error happened. The developers have been notified. Please try again later.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCrimeByYearAndMonth(
|
||||
year: number,
|
||||
month: number | 'all'
|
||||
month: number | "all",
|
||||
): Promise<ICrimesByYearAndMonth[]> {
|
||||
const instrumentationService = getInjection('IInstrumentationService');
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
'District Crime Data',
|
||||
"District Crime Data",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
try {
|
||||
// Build where clause conditionally based on provided parameters
|
||||
const whereClause: any = {
|
||||
year: year, // Always filter by year now since "all" is removed
|
||||
year: year,
|
||||
};
|
||||
|
||||
// Only add month to filter if it's not "all"
|
||||
if (month !== 'all') {
|
||||
if (month !== "all") {
|
||||
whereClause.month = month;
|
||||
}
|
||||
|
||||
|
@ -231,7 +276,7 @@ export async function getCrimeByYearAndMonth(
|
|||
select: {
|
||||
name: true,
|
||||
geographics: {
|
||||
where: { year }, // Match geographics to selected year
|
||||
where: { year },
|
||||
select: {
|
||||
address: true,
|
||||
land_area: true,
|
||||
|
@ -241,7 +286,7 @@ export async function getCrimeByYearAndMonth(
|
|||
},
|
||||
},
|
||||
demographics: {
|
||||
where: { year }, // Match demographics to selected year
|
||||
where: { year },
|
||||
select: {
|
||||
number_of_unemployed: true,
|
||||
population: true,
|
||||
|
@ -275,15 +320,12 @@ export async function getCrimeByYearAndMonth(
|
|||
},
|
||||
});
|
||||
|
||||
// Process the data to transform geographics and demographics from array to single object
|
||||
const processedCrimes = crimes.map((crime) => {
|
||||
return {
|
||||
...crime,
|
||||
districts: {
|
||||
...crime.districts,
|
||||
// Convert geographics array to single object matching the year
|
||||
geographics: crime.districts.geographics[0] || null,
|
||||
// Convert demographics array to single object matching the year
|
||||
demographics: crime.districts.demographics[0] || null,
|
||||
},
|
||||
};
|
||||
|
@ -297,17 +339,17 @@ export async function getCrimeByYearAndMonth(
|
|||
|
||||
if (err instanceof AuthenticationError) {
|
||||
throw new AuthenticationError(
|
||||
'There was an error with the credentials. Please try again or contact support.'
|
||||
"There was an error with the credentials. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const crashReporterService = getInjection('ICrashReporterService');
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(err);
|
||||
throw new Error(
|
||||
'An error happened. The developers have been notified. Please try again later.'
|
||||
"An error happened. The developers have been notified. Please try again later.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -319,37 +361,77 @@ export async function getCrimeByYearAndMonth(
|
|||
*/
|
||||
export async function calculateDistances(
|
||||
p_unit_id?: string,
|
||||
p_district_id?: string
|
||||
p_district_id?: string,
|
||||
): Promise<IDistanceResult[]> {
|
||||
const instrumentationService = getInjection('IInstrumentationService');
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
'Calculate Distances',
|
||||
"Calculate Distances",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.rpc(
|
||||
'calculate_unit_incident_distances',
|
||||
"calculate_unit_incident_distances",
|
||||
{
|
||||
p_unit_id: p_unit_id || null,
|
||||
p_district_id: p_district_id || null,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Error calculating distances:', error);
|
||||
console.error("Error calculating distances:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return data as IDistanceResult[] || [];
|
||||
} catch (error) {
|
||||
const crashReporterService = getInjection('ICrashReporterService');
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(error);
|
||||
console.error('Failed to calculate distances:', error);
|
||||
console.error("Failed to calculate distances:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCrimesTypes(): Promise<string[]> {
|
||||
const instrumentationService = getInjection("IInstrumentationService");
|
||||
return await instrumentationService.instrumentServerAction(
|
||||
"Crime Types",
|
||||
{ recordResponse: true },
|
||||
async () => {
|
||||
try {
|
||||
const types = await db.crimes.findMany({
|
||||
distinct: ["source_type"],
|
||||
select: {
|
||||
source_type: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Return a clean array of strings with no nulls
|
||||
return types
|
||||
.map((t) => t.source_type)
|
||||
.filter((t): t is string => t !== null && t !== undefined);
|
||||
|
||||
} catch (err) {
|
||||
if (err instanceof InputParseError) {
|
||||
throw new InputParseError(err.message);
|
||||
}
|
||||
|
||||
if (err instanceof AuthenticationError) {
|
||||
throw new AuthenticationError(
|
||||
"There was an error with the credentials. Please try again or contact support.",
|
||||
);
|
||||
}
|
||||
|
||||
const crashReporterService = getInjection("ICrashReporterService");
|
||||
crashReporterService.report(err);
|
||||
throw new Error(
|
||||
"An error happened. The developers have been notified. Please try again later.",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ interface CrimeSidebarProps {
|
|||
selectedMonth?: number | "all"
|
||||
crimes: ICrimes[]
|
||||
isLoading?: boolean
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export default function CrimeSidebar({
|
||||
|
@ -37,6 +38,7 @@ export default function CrimeSidebar({
|
|||
selectedMonth,
|
||||
crimes = [],
|
||||
isLoading = false,
|
||||
sourceType = "cbt",
|
||||
}: CrimeSidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
||||
const [activeTab, setActiveTab] = useState("incidents")
|
||||
|
@ -60,13 +62,19 @@ export default function CrimeSidebar({
|
|||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// Set default tab based on source type
|
||||
useEffect(() => {
|
||||
if (sourceType === "cbu") {
|
||||
setActiveTab("incidents")
|
||||
}
|
||||
}, [sourceType])
|
||||
|
||||
// Format date with selected year and month if provided
|
||||
const getDisplayDate = () => {
|
||||
// If we have a specific month selected, use that for display
|
||||
if (selectedMonth && selectedMonth !== 'all') {
|
||||
const date = new Date()
|
||||
date.setFullYear(selectedYear)
|
||||
date.setMonth(Number(selectedMonth) - 1) // Month is 0-indexed in JS Date
|
||||
date.setMonth(Number(selectedMonth) - 1)
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
|
@ -74,7 +82,6 @@ export default function CrimeSidebar({
|
|||
}).format(date)
|
||||
}
|
||||
|
||||
// Otherwise show today's date
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
|
@ -91,7 +98,6 @@ export default function CrimeSidebar({
|
|||
hour12: true
|
||||
}).format(currentTime)
|
||||
|
||||
// Generate a time period display for the current view
|
||||
const getTimePeriodDisplay = () => {
|
||||
if (selectedMonth && selectedMonth !== 'all') {
|
||||
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
||||
|
@ -99,31 +105,8 @@ export default function CrimeSidebar({
|
|||
return `${selectedYear} - All months`
|
||||
}
|
||||
|
||||
// Function to fly to incident location when clicked
|
||||
const handleIncidentClick = (incident: any) => {
|
||||
if (!map || !incident.longitude || !incident.latitude) return
|
||||
|
||||
// Fly to the incident location
|
||||
// map.flyTo({
|
||||
// center: [incident.longitude, incident.latitude],
|
||||
// zoom: 15,
|
||||
// pitch: 0,
|
||||
// bearing: 0,
|
||||
// duration: 1500,
|
||||
// easing: (t) => t * (2 - t), // easeOutQuad
|
||||
// })
|
||||
|
||||
// // Create and dispatch a custom event for the incident click
|
||||
// const customEvent = new CustomEvent("incident_click", {
|
||||
// detail: incident,
|
||||
// bubbles: true
|
||||
// })
|
||||
|
||||
// if (map.getMap().getCanvas()) {
|
||||
// map.getMap().getCanvas().dispatchEvent(customEvent)
|
||||
// } else {
|
||||
// document.dispatchEvent(customEvent)
|
||||
// }
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -135,7 +118,6 @@ export default function CrimeSidebar({
|
|||
<div className="relative h-full flex items-stretch">
|
||||
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
||||
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
||||
{/* Header with improved styling */}
|
||||
<CardHeader className="p-0 pb-4 shrink-0 relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
<Button
|
||||
|
@ -155,6 +137,11 @@ export default function CrimeSidebar({
|
|||
<div>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
Crime Analysis
|
||||
{sourceType && (
|
||||
<span className="ml-2 text-xs font-normal px-2 py-1 bg-sidebar-accent rounded-full">
|
||||
{sourceType.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
{!isLoading && (
|
||||
<CardDescription className="text-sm text-sidebar-foreground/70">
|
||||
|
@ -165,7 +152,6 @@ export default function CrimeSidebar({
|
|||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* Improved tabs with pill style */}
|
||||
<Tabs
|
||||
defaultValue="incidents"
|
||||
className="w-full flex-1 flex flex-col overflow-hidden"
|
||||
|
@ -173,12 +159,14 @@ export default function CrimeSidebar({
|
|||
onValueChange={setActiveTab}
|
||||
>
|
||||
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
|
||||
|
||||
<TabsTrigger
|
||||
value="incidents"
|
||||
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||
>
|
||||
Dashboard
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="statistics"
|
||||
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||
|
@ -211,6 +199,7 @@ export default function CrimeSidebar({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
||||
<SidebarIncidentsTab
|
||||
crimeStats={crimeStats}
|
||||
|
@ -226,6 +215,8 @@ export default function CrimeSidebar({
|
|||
handleIncidentClick={handleIncidentClick}
|
||||
activeIncidentTab={activeIncidentTab}
|
||||
setActiveIncidentTab={setActiveIncidentTab}
|
||||
sourceType={sourceType}
|
||||
// setActiveTab={setActiveTab} // Pass setActiveTab function
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
@ -234,11 +225,13 @@ export default function CrimeSidebar({
|
|||
crimeStats={crimeStats}
|
||||
selectedMonth={selectedMonth}
|
||||
selectedYear={selectedYear}
|
||||
sourceType={sourceType}
|
||||
crimes={crimes}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="info" className="m-0 p-0 space-y-4">
|
||||
<SidebarInfoTab />
|
||||
<SidebarInfoTab sourceType={sourceType} />
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar } from 'lucide-react'
|
||||
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar, ArrowRight, RefreshCw } from 'lucide-react'
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
||||
import { Badge } from "@/app/_components/ui/badge"
|
||||
|
@ -59,8 +59,9 @@ export function SidebarIncidentsTab({
|
|||
handlePageChange,
|
||||
handleIncidentClick,
|
||||
activeIncidentTab,
|
||||
setActiveIncidentTab
|
||||
}: SidebarIncidentsTabProps) {
|
||||
setActiveIncidentTab,
|
||||
sourceType = "cbt"
|
||||
}: SidebarIncidentsTabProps & { sourceType?: string }) {
|
||||
const topCategories = crimeStats.categoryCounts ?
|
||||
Object.entries(crimeStats.categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
|
@ -70,6 +71,58 @@ export function SidebarIncidentsTab({
|
|||
return { type, count, percentage }
|
||||
}) : []
|
||||
|
||||
// If source type is CBU, display warning instead of regular content
|
||||
if (sourceType === "cbu") {
|
||||
return (
|
||||
<Card className="bg-gradient-to-r from-emerald-900/20 to-emerald-800/10 border border-emerald-500/30">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
<div className="bg-emerald-500/20 rounded-full p-3 mb-3">
|
||||
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
|
||||
|
||||
<p className="text-white/80 mb-4">
|
||||
The CBU data source only provides aggregated statistics without detailed incident information.
|
||||
</p>
|
||||
|
||||
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white/60 text-sm">Current Data Source:</span>
|
||||
<span className="font-medium text-emerald-400 text-sm">CBU</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60 text-sm">Recommended:</span>
|
||||
<span className="font-medium text-blue-400 text-sm">CBT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white/70 text-sm mb-5">
|
||||
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Change Data Source
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
|
||||
<p className="text-xs text-white/60">
|
||||
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Enhanced info card */}
|
||||
|
@ -93,7 +146,7 @@ export function SidebarIncidentsTab({
|
|||
<span className="text-sidebar-foreground/70">{location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
||||
<AlertTriangle className="h-5 w-5 text-emerald-400" />
|
||||
<span>
|
||||
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
||||
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
||||
|
@ -116,8 +169,8 @@ export function SidebarIncidentsTab({
|
|||
<SystemStatusCard
|
||||
title="Recent Cases"
|
||||
status={`${crimeStats?.recentIncidents?.length || 0}`}
|
||||
statusIcon={<Clock className="h-4 w-4 text-amber-400" />}
|
||||
statusColor="text-amber-400"
|
||||
statusIcon={<Clock className="h-4 w-4 text-emerald-400" />}
|
||||
statusColor="text-emerald-400"
|
||||
updatedTime="Last 30 days"
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
borderColor="border-sidebar-border"
|
||||
|
@ -243,7 +296,7 @@ export function SidebarIncidentsTab({
|
|||
<div key={monthKey} className="mb-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5 text-amber-400" />
|
||||
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
|
|
|
@ -5,7 +5,11 @@ import { Separator } from "@/app/_components/ui/separator"
|
|||
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
||||
import { SidebarSection } from "../components/sidebar-section"
|
||||
|
||||
export function SidebarInfoTab() {
|
||||
interface SidebarInfoTabProps {
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export function SidebarInfoTab({ sourceType = "cbt" }: SidebarInfoTabProps) {
|
||||
return (
|
||||
<>
|
||||
<SidebarSection title="Map Legend" icon={<Layers className="h-4 w-4 text-green-400" />}>
|
||||
|
@ -29,123 +33,175 @@ export function SidebarInfoTab() {
|
|||
|
||||
<Separator className="bg-white/20 my-3" />
|
||||
|
||||
{/* Show different map markers based on source type */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium mb-2 text-sm">Map Markers</h4>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span>Individual Incident</span>
|
||||
</div>
|
||||
{sourceType === "cbt" ? (
|
||||
// Detailed incidents for CBT
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span>Individual Incident</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-pink-400 flex items-center justify-center text-[10px] text-white">5</div>
|
||||
<span className="font-bold">Incident Cluster</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Simplified view for CBU
|
||||
<>
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-blue-400 flex items-center justify-center text-[10px] text-white">12</div>
|
||||
<span className="font-bold">District Crime Count</span>
|
||||
</div>
|
||||
<div className="p-1.5 text-white/70">
|
||||
Shows aggregated crime counts by district. Size indicates relative crime volume.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SidebarSection>
|
||||
|
||||
{/* Show layers info based on source type */}
|
||||
<SidebarSection title="Map Layers" icon={<Map className="h-4 w-4 text-blue-400" />}>
|
||||
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
|
||||
<CardContent className="p-4 text-xs space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-cyan-400" />
|
||||
<span>Incidents Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows individual crime incidents as map markers. Each marker represents a single crime report and is color-coded by category.
|
||||
Click on any marker to see detailed information about the incident.
|
||||
</p>
|
||||
</div>
|
||||
{sourceType === "cbt" ? (
|
||||
// Show all layers for CBT
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-cyan-400" />
|
||||
<span>Incidents Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows individual crime incidents as map markers. Each marker represents a single crime report and is color-coded by category.
|
||||
Click on any marker to see detailed information about the incident.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Box className="h-4 w-4 text-pink-400" />
|
||||
<span>Clusters Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Groups nearby incidents into clusters for better visibility at lower zoom levels. Numbers show incident count in each cluster.
|
||||
Clusters are color-coded by size: blue (small), yellow (medium), pink (large).
|
||||
Click on a cluster to zoom in and see individual incidents.
|
||||
</p>
|
||||
<div className="flex items-center gap-6 pl-6 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-[#51bbd6]"></div>
|
||||
<span>1-5</span>
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Box className="h-4 w-4 text-pink-400" />
|
||||
<span>Clusters Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Groups nearby incidents into clusters for better visibility at lower zoom levels. Numbers show incident count in each cluster.
|
||||
Clusters are color-coded by size: blue (small), yellow (medium), pink (large).
|
||||
Click on a cluster to zoom in and see individual incidents.
|
||||
</p>
|
||||
<div className="flex items-center gap-6 pl-6 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-[#51bbd6]"></div>
|
||||
<span>1-5</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-[#f1f075]"></div>
|
||||
<span>6-15</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-[#f28cb1]"></div>
|
||||
<span>15+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-[#f1f075]"></div>
|
||||
<span>6-15</span>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Thermometer className="h-4 w-4 text-orange-400" />
|
||||
<span>Heatmap Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows crime density across regions, with warmer colors (red, orange) indicating higher crime concentration
|
||||
and cooler colors (blue) showing lower concentration. Useful for identifying crime hotspots.
|
||||
</p>
|
||||
<div className="pl-6 pt-1">
|
||||
<div className="h-2 w-full rounded-full bg-gradient-to-r from-blue-600 via-yellow-400 to-red-600"></div>
|
||||
<div className="flex justify-between text-[10px] mt-1 text-white/70">
|
||||
<span>Low</span>
|
||||
<span>Density</span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-[#f28cb1]"></div>
|
||||
<span>15+</span>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Users className="h-4 w-4 text-blue-400" />
|
||||
<span>Units Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Displays police and security units as blue circles with connecting lines to nearby incidents.
|
||||
Units are labeled and can be clicked to show more information and related incident details.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pl-6 pt-1">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white bg-[#1e40af]"></div>
|
||||
<span>Police/Security Unit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Clock className="h-4 w-4 text-yellow-400" />
|
||||
<span>Timeline Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows time patterns of crime incidents with color-coded circles representing average times of day when
|
||||
incidents occur in each district. Click for detailed time distribution analysis.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2 pl-6 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFEB3B]"></div>
|
||||
<span>Morning</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF9800]"></div>
|
||||
<span>Afternoon</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#3F51B5]"></div>
|
||||
<span>Evening</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#263238]"></div>
|
||||
<span>Night</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Show limited layers info for CBU
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Box className="h-4 w-4 text-blue-400" />
|
||||
<span>District Crime Data</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows aggregated crime statistics by district. Each point represents the total crime count for a district.
|
||||
Points are color-coded based on crime level: green (low), yellow (medium), red (high).
|
||||
</p>
|
||||
<p className="text-white/70 pl-6 mt-1">
|
||||
The size of each point indicates the relative number of crimes reported in that district.
|
||||
</p>
|
||||
<div className="flex items-center gap-6 pl-6 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></div>
|
||||
<span>Low</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.medium }}></div>
|
||||
<span>Medium</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.high }}></div>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Thermometer className="h-4 w-4 text-orange-400" />
|
||||
<span>Heatmap Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows crime density across regions, with warmer colors (red, orange) indicating higher crime concentration
|
||||
and cooler colors (blue) showing lower concentration. Useful for identifying crime hotspots.
|
||||
</p>
|
||||
<div className="pl-6 pt-1">
|
||||
<div className="h-2 w-full rounded-full bg-gradient-to-r from-blue-600 via-yellow-400 to-red-600"></div>
|
||||
<div className="flex justify-between text-[10px] mt-1 text-white/70">
|
||||
<span>Low</span>
|
||||
<span>Density</span>
|
||||
<span>High</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Users className="h-4 w-4 text-blue-400" />
|
||||
<span>Units Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Displays police and security units as blue circles with connecting lines to nearby incidents.
|
||||
Units are labeled and can be clicked to show more information and related incident details.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pl-6 pt-1">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white bg-[#1e40af]"></div>
|
||||
<span>Police/Security Unit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||
<Clock className="h-4 w-4 text-yellow-400" />
|
||||
<span>Timeline Layer</span>
|
||||
</h4>
|
||||
<p className="text-white/70 pl-6">
|
||||
Shows time patterns of crime incidents with color-coded circles representing average times of day when
|
||||
incidents occur in each district. Click for detailed time distribution analysis.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2 pl-6 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FFEB3B]"></div>
|
||||
<span>Morning</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF9800]"></div>
|
||||
<span>Afternoon</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#3F51B5]"></div>
|
||||
<span>Evening</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#263238]"></div>
|
||||
<span>Night</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SidebarSection>
|
||||
|
@ -170,6 +226,12 @@ export function SidebarInfoTab() {
|
|||
<span>Last Updated</span>
|
||||
<span className="font-medium">June 18, 2024</span>
|
||||
</div>
|
||||
{sourceType && (
|
||||
<div className="flex justify-between mt-1">
|
||||
<span>Data Source</span>
|
||||
<span className="font-medium">{sourceType.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -203,17 +265,20 @@ export function SidebarInfoTab() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
|
||||
{/* Show incident details help only for CBT */}
|
||||
{sourceType === "cbt" && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="bg-emerald-900/50 p-1.5 rounded-md">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Incidents</span>
|
||||
<p className="text-white/70 mt-1">
|
||||
Click on incident markers to view details about specific crime reports.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Incidents</span>
|
||||
<p className="text-white/70 mt-1">
|
||||
Click on incident markers to view details about specific crime reports.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SidebarSection>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText } from 'lucide-react'
|
||||
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText, BarChart2, TrendingUp, MapPin, Clock, AlertCircle, Info, ArrowRight, RefreshCw } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
|
||||
import { Separator } from "@/app/_components/ui/separator"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
|
@ -9,19 +9,275 @@ import { StatCard } from "../components/stat-card"
|
|||
import { CrimeTypeCard, ICrimeTypeCardProps } from "../components/crime-type-card"
|
||||
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
|
||||
import { MONTHS } from '@/app/_utils/const/common'
|
||||
|
||||
import { ICrimes } from '@/app/_utils/types/crimes'
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
|
||||
interface ISidebarStatisticsTabProps {
|
||||
crimeStats: ICrimeAnalytics
|
||||
selectedMonth?: number | "all"
|
||||
selectedYear: number
|
||||
sourceType?: string
|
||||
crimes?: ICrimes[]
|
||||
}
|
||||
|
||||
// Component for rendering bar chart for monthly trends
|
||||
const MonthlyTrendChart = ({ monthlyData = Array(12).fill(0) }: { monthlyData: number[] }) => (
|
||||
<div className="h-32 flex items-end gap-1 mt-2">
|
||||
{monthlyData.map((count: number, i: number) => {
|
||||
const maxCount = Math.max(...monthlyData);
|
||||
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md"
|
||||
style={{
|
||||
height: `${Math.max(5, height)}%`,
|
||||
opacity: 0.7 + (i / 24)
|
||||
}}
|
||||
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Component for rendering yearly trend chart
|
||||
const YearlyTrendChart = ({ crimes }: { crimes: ICrimes[] }) => {
|
||||
const yearCounts = React.useMemo(() => {
|
||||
const counts = new Map<number, number>();
|
||||
const years = Array.from(new Set(crimes.map(c => c.year))).filter((y): y is number => typeof y === "number").sort();
|
||||
years.forEach(year => {
|
||||
counts.set(year, 0);
|
||||
});
|
||||
crimes.forEach(crime => {
|
||||
if (crime.year) {
|
||||
const currentCount = counts.get(crime.year) || 0;
|
||||
counts.set(crime.year, currentCount + (crime.number_of_crime || 0));
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [crimes]);
|
||||
|
||||
const years = Array.from(yearCounts.keys()).sort();
|
||||
const values = years.map(year => yearCounts.get(year) || 0);
|
||||
|
||||
if (values.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-white/50 text-sm">
|
||||
No yearly data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="h-32 flex flex-col justify-end">
|
||||
<div className="flex justify-between items-end h-full">
|
||||
{years.map((year, i) => {
|
||||
const count = values[i];
|
||||
const maxCount = Math.max(...values);
|
||||
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col items-center gap-1 w-full">
|
||||
<div
|
||||
className="bg-blue-500 w-8 rounded-t-md"
|
||||
style={{
|
||||
height: `${Math.max(5, height)}%`,
|
||||
opacity: 0.6 + (i / 10)
|
||||
}}
|
||||
title={`${year}: ${count} incidents`}
|
||||
/>
|
||||
<span className="text-[10px] text-white/60">{year}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for district distribution chart
|
||||
const DistrictDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => {
|
||||
const districtData = React.useMemo(() => {
|
||||
const districtCounts = new Map<string, { name: string, count: number }>();
|
||||
crimes.forEach(crime => {
|
||||
if (crime.district_id && crime.districts?.name) {
|
||||
const districtId = crime.district_id;
|
||||
const districtName = crime.districts.name;
|
||||
const count = crime.number_of_crime || 0;
|
||||
|
||||
if (districtCounts.has(districtId)) {
|
||||
const current = districtCounts.get(districtId)!;
|
||||
districtCounts.set(districtId, {
|
||||
name: districtName,
|
||||
count: current.count + count
|
||||
});
|
||||
} else {
|
||||
districtCounts.set(districtId, {
|
||||
name: districtName,
|
||||
count: count
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sortedDistricts = Array.from(districtCounts.values())
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
|
||||
const totalIncidents = sortedDistricts.reduce((sum, district) => sum + district.count, 0);
|
||||
|
||||
return sortedDistricts.map(district => ({
|
||||
name: district.name,
|
||||
count: district.count,
|
||||
percentage: Math.round((district.count / totalIncidents) * 100)
|
||||
}));
|
||||
}, [crimes]);
|
||||
|
||||
if (districtData.length === 0) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-r from-indigo-900/30 to-indigo-800/20 border border-indigo-900/20">
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-indigo-400" />
|
||||
Top Districts
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-white/60">Crime distribution by area</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-2 text-center text-white/50 text-sm">
|
||||
No district data available
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-r from-indigo-900/30 to-indigo-800/20 border border-indigo-900/20">
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-indigo-400" />
|
||||
Top Districts
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-white/60">Crime distribution by area</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-2">
|
||||
<div className="space-y-2 mt-2">
|
||||
{districtData.map((district, index) => (
|
||||
<div key={index} className="flex flex-col gap-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{district.name}</span>
|
||||
<span className="font-medium">{district.count} incidents</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 rounded-full"
|
||||
style={{ width: `${district.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for time of day distribution
|
||||
const TimeOfDayDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => {
|
||||
const timeData = React.useMemo(() => {
|
||||
const periods = [
|
||||
{ name: "Morning (6AM-12PM)", start: 6, end: 11, color: "from-yellow-400 to-yellow-300" },
|
||||
{ name: "Afternoon (12PM-6PM)", start: 12, end: 17, color: "from-orange-500 to-orange-400" },
|
||||
{ name: "Evening (6PM-12AM)", start: 18, end: 23, color: "from-blue-600 to-blue-500" },
|
||||
{ name: "Night (12AM-6AM)", start: 0, end: 5, color: "from-gray-800 to-gray-700" },
|
||||
];
|
||||
|
||||
const counts = periods.map(p => ({
|
||||
period: p.name,
|
||||
count: 0,
|
||||
percentage: 0,
|
||||
color: p.color
|
||||
}));
|
||||
|
||||
let totalCounted = 0;
|
||||
crimes.forEach(crime => {
|
||||
if (!crime.crime_incidents) return;
|
||||
|
||||
crime.crime_incidents.forEach((incident) => {
|
||||
if (incident.timestamp) {
|
||||
const date = new Date(incident.timestamp);
|
||||
const hour = date.getHours();
|
||||
|
||||
for (let i = 0; i < periods.length; i++) {
|
||||
if (hour >= periods[i].start && hour <= periods[i].end) {
|
||||
counts[i].count++;
|
||||
totalCounted++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (totalCounted > 0) {
|
||||
counts.forEach(item => {
|
||||
item.percentage = Math.round((item.count / totalCounted) * 100);
|
||||
});
|
||||
}
|
||||
|
||||
if (totalCounted === 0) {
|
||||
counts[0].percentage = 15;
|
||||
counts[1].percentage = 25;
|
||||
counts[2].percentage = 40;
|
||||
counts[3].percentage = 20;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}, [crimes]);
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-r from-purple-900/30 to-purple-800/20 border border-purple-900/20">
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-purple-400" />
|
||||
Time of Day
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-white/60">When incidents occur</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-2">
|
||||
<div className="h-8 rounded-lg overflow-hidden flex mt-2">
|
||||
{timeData.map((time, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`bg-gradient-to-r ${time.color} h-full`}
|
||||
style={{ width: `${time.percentage}%` }}
|
||||
title={`${time.period}: ${time.percentage}%`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mt-1 text-white/70">
|
||||
{timeData.map((time, index) => (
|
||||
<div key={index} className="flex flex-col items-center">
|
||||
<div className={`w-3 h-3 rounded-full bg-gradient-to-r ${time.color}`} />
|
||||
<span className="text-[9px] mt-0.5">{time.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export function SidebarStatisticsTab({
|
||||
crimeStats,
|
||||
selectedMonth = "all",
|
||||
selectedYear
|
||||
}: ISidebarStatisticsTabProps) {
|
||||
selectedYear,
|
||||
sourceType = "cbt",
|
||||
crimes = []
|
||||
}: ISidebarStatisticsTabProps & { crimes?: ICrimes[] }) {
|
||||
const topCategories = crimeStats.categoryCounts ?
|
||||
Object.entries(crimeStats.categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
|
@ -31,8 +287,64 @@ export function SidebarStatisticsTab({
|
|||
return { type, count, percentage }
|
||||
}) : []
|
||||
|
||||
|
||||
// If source type is CBU, display warning instead of regular content
|
||||
if (sourceType === "cbu") {
|
||||
return (
|
||||
<Card className="bg-gradient-to-r from-emerald-900/20 to-emerald-800/10 border border-emerald-500/30">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
<div className="bg-emerald-500/20 rounded-full p-3 mb-3">
|
||||
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
|
||||
|
||||
<p className="text-white/80 mb-4">
|
||||
The CBU data source only provides aggregated statistics without detailed incident information.
|
||||
</p>
|
||||
|
||||
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white/60 text-sm">Current Data Source:</span>
|
||||
<span className="font-medium text-emerald-400 text-sm">CBU</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/60 text-sm">Recommended:</span>
|
||||
<span className="font-medium text-blue-400 text-sm">CBT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-white/70 text-sm mb-5">
|
||||
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Change Data Source
|
||||
</Button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
|
||||
<p className="text-xs text-white/60">
|
||||
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
|
@ -42,27 +354,7 @@ export function SidebarStatisticsTab({
|
|||
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
<div className="h-32 flex items-end gap-1 mt-2">
|
||||
{crimeStats.incidentsByMonth.map((count: number, i: number) => {
|
||||
const maxCount = Math.max(...crimeStats.incidentsByMonth)
|
||||
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md",
|
||||
selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "from-amber-500 to-amber-400" : ""
|
||||
)}
|
||||
style={{
|
||||
height: `${Math.max(5, height)}%`,
|
||||
opacity: 0.7 + (i / 24)
|
||||
}}
|
||||
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<MonthlyTrendChart monthlyData={crimeStats.incidentsByMonth} />
|
||||
<div className="flex justify-between mt-2 text-[10px] text-white/60">
|
||||
{MONTHS.map((month, i) => (
|
||||
<span key={i} className={cn("w-1/12 text-center", selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "text-amber-400" : "")}>
|
||||
|
@ -109,32 +401,57 @@ export function SidebarStatisticsTab({
|
|||
</div>
|
||||
</SidebarSection>
|
||||
|
||||
<Card className="bg-gradient-to-r from-blue-900/30 to-blue-800/20 border border-blue-900/20 overflow-hidden">
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<BarChart2 className="h-4 w-4 text-blue-400" />
|
||||
Yearly Trends
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-white/60">Historical Overview</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3">
|
||||
<YearlyTrendChart crimes={crimes} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator className="bg-white/20 my-4" />
|
||||
|
||||
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
|
||||
<div className="space-y-3">
|
||||
{topCategories.length > 0 ? (
|
||||
topCategories.map((category: ICrimeTypeCardProps) => (
|
||||
<CrimeTypeCard
|
||||
key={category.type}
|
||||
type={category.type}
|
||||
count={category.count}
|
||||
percentage={category.percentage}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-white/40" />
|
||||
<p className="text-sm text-white/70">No crime data available</p>
|
||||
<p className="text-xs text-white/50">Try selecting a different time period</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
<TimeOfDayDistribution crimes={crimes} sourceType={sourceType} />
|
||||
|
||||
<div className="mt-4">
|
||||
<DistrictDistribution crimes={crimes} sourceType={sourceType} />
|
||||
</div>
|
||||
|
||||
{sourceType === "cbt" && (
|
||||
<>
|
||||
<Separator className="bg-white/20 my-4" />
|
||||
|
||||
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
|
||||
<div className="space-y-3">
|
||||
{topCategories.length > 0 ? (
|
||||
topCategories.map((category: ICrimeTypeCardProps) => (
|
||||
<CrimeTypeCard
|
||||
key={category.type}
|
||||
type={category.type}
|
||||
count={category.count}
|
||||
percentage={category.percentage}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-white/40" />
|
||||
<p className="text-sm text-white/70">No crime data available</p>
|
||||
<p className="text-xs text-white/50">Try selecting a different time period</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/app/_components/ui/select"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Skeleton } from "../../ui/skeleton"
|
||||
|
||||
interface SourceTypeSelectorProps {
|
||||
selectedSourceType: string
|
||||
onSourceTypeChange: (sourceType: string) => void
|
||||
availableSourceTypes: string[]
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export default function SourceTypeSelector({
|
||||
selectedSourceType,
|
||||
onSourceTypeChange,
|
||||
availableSourceTypes,
|
||||
className,
|
||||
isLoading = false,
|
||||
}: SourceTypeSelectorProps) {
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// This will ensure that the document is only used in the client-side context
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
const container = isClient ? document.getElementById("root") : null
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mapboxgl-category-selector">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-8">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedSourceType}
|
||||
onValueChange={(value) => onSourceTypeChange(value)}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Crime Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
container={containerRef.current || container || undefined}
|
||||
style={{ zIndex: 2000 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{availableSourceTypes.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -3,19 +3,19 @@
|
|||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
|
||||
import { ChevronDown, Layers, Siren } from "lucide-react"
|
||||
import { ChevronDown, Siren } from "lucide-react"
|
||||
import { IconMessage } from "@tabler/icons-react"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ITooltips } from "./tooltips"
|
||||
import type { ITooltips } from "./tooltips"
|
||||
import MonthSelector from "../month-selector"
|
||||
import YearSelector from "../year-selector"
|
||||
import CategorySelector from "../category-selector"
|
||||
import SourceTypeSelector from "../source-type-selector"
|
||||
|
||||
// Define the additional tools and features
|
||||
const additionalTooltips = [
|
||||
{ id: "reports" as ITooltips, icon: <IconMessage size={20} />, label: "Police Report" },
|
||||
{ id: "layers" as ITooltips, icon: <Layers size={20} />, label: "Map Layers" },
|
||||
{ id: "alerts" as ITooltips, icon: <Siren size={20} />, label: "Active Alerts" },
|
||||
]
|
||||
|
||||
|
@ -28,7 +28,10 @@ interface AdditionalTooltipsProps {
|
|||
setSelectedMonth: (month: number | "all") => void
|
||||
selectedCategory: string | "all"
|
||||
setSelectedCategory: (category: string | "all") => void
|
||||
selectedSourceType: string
|
||||
setSelectedSourceType: (sourceType: string) => void
|
||||
availableYears?: (number | null)[]
|
||||
availableSourceTypes?: string[]
|
||||
categories?: string[]
|
||||
panicButtonTriggered?: boolean
|
||||
}
|
||||
|
@ -42,7 +45,10 @@ export default function AdditionalTooltips({
|
|||
setSelectedMonth,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
availableYears = [2022, 2023, 2024],
|
||||
selectedSourceType = "cbu",
|
||||
setSelectedSourceType,
|
||||
availableYears = [],
|
||||
availableSourceTypes = [],
|
||||
categories = [],
|
||||
panicButtonTriggered = false,
|
||||
}: AdditionalTooltipsProps) {
|
||||
|
@ -54,110 +60,138 @@ export default function AdditionalTooltips({
|
|||
|
||||
useEffect(() => {
|
||||
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
|
||||
onControlChange("alerts");
|
||||
}
|
||||
}, [panicButtonTriggered, activeControl, onControlChange]);
|
||||
onControlChange("alerts")
|
||||
}
|
||||
}, [panicButtonTriggered, activeControl, onControlChange])
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
const isControlDisabled = (controlId: ITooltips) => {
|
||||
// When source type is CBU, disable all controls except for layers
|
||||
return selectedSourceType === "cbu" && controlId !== "layers"
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
{additionalTooltips.map((control) => (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="medium"
|
||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||
{additionalTooltips.map((control) => {
|
||||
const isButtonDisabled = isControlDisabled(control.id)
|
||||
|
||||
return (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="medium"
|
||||
className={`h-8 w-8 rounded-md ${isButtonDisabled
|
||||
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||
: activeControl === control.id
|
||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
|
||||
onClick={() => onControlChange?.(control.id)}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
|
||||
onClick={() => onControlChange?.(control.id)}
|
||||
disabled={isButtonDisabled}
|
||||
aria-disabled={isButtonDisabled}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
<Tooltip>
|
||||
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
onClick={() => setShowSelectors(!showSelectors)}
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
<span className="sr-only">Filters</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
container={containerRef.current || container || undefined}
|
||||
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
|
||||
align="end"
|
||||
style={{ zIndex: 2000 }}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Year:</span>
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Month:</span>
|
||||
<MonthSelector
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={setSelectedMonth}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Category:</span>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
onClick={() => setShowSelectors(!showSelectors)}
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
<span className="sr-only">Filters</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
container={containerRef.current || container || undefined}
|
||||
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
|
||||
align="end"
|
||||
style={{ zIndex: 2000 }}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Source:</span>
|
||||
<SourceTypeSelector
|
||||
availableSourceTypes={availableSourceTypes}
|
||||
selectedSourceType={selectedSourceType}
|
||||
onSourceTypeChange={setSelectedSourceType}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Year:</span>
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Month:</span>
|
||||
<MonthSelector
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={setSelectedMonth}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Category:</span>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{showSelectors && (
|
||||
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
className="w-[100px]"
|
||||
/>
|
||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
className="w-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
{showSelectors && (
|
||||
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
|
||||
<SourceTypeSelector
|
||||
availableSourceTypes={availableSourceTypes}
|
||||
selectedSourceType={selectedSourceType}
|
||||
onSourceTypeChange={setSelectedSourceType}
|
||||
className="w-[80px]"
|
||||
/>
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
className="w-[80px]"
|
||||
/>
|
||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[80px]" />
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
className="w-[80px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,17 +2,16 @@
|
|||
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
||||
import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Thermometer, Shield, Users } from "lucide-react"
|
||||
import { ITooltips } from "./tooltips"
|
||||
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
|
||||
|
||||
import { AlertTriangle, Building, Car, Thermometer } from "lucide-react"
|
||||
import type { ITooltips } from "./tooltips"
|
||||
import { IconChartBubble, IconClock } from "@tabler/icons-react"
|
||||
|
||||
// Define the primary crime data controls
|
||||
const crimeTooltips = [
|
||||
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
||||
{ id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" },
|
||||
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
|
||||
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
|
||||
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
|
||||
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
|
||||
{ id: "timeline" as ITooltips, icon: <IconClock size={20} />, label: "Time Analysis" },
|
||||
]
|
||||
|
@ -20,43 +19,61 @@ const crimeTooltips = [
|
|||
interface CrimeTooltipsProps {
|
||||
activeControl?: string
|
||||
onControlChange?: (controlId: ITooltips) => void
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) {
|
||||
export default function CrimeTooltips({ activeControl, onControlChange, sourceType = "cbt" }: CrimeTooltipsProps) {
|
||||
const handleControlClick = (controlId: ITooltips) => {
|
||||
console.log("Clicked control:", controlId);
|
||||
// Force the value to be set when clicking
|
||||
if (onControlChange) {
|
||||
onControlChange(controlId);
|
||||
console.log("Control changed to:", controlId);
|
||||
}
|
||||
};
|
||||
// If control is disabled, don't do anything
|
||||
if (isDisabled(controlId)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Force the value to be set when clicking
|
||||
if (onControlChange) {
|
||||
onControlChange(controlId)
|
||||
console.log("Control changed to:", controlId)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which controls should be disabled based on source type
|
||||
const isDisabled = (controlId: ITooltips) => {
|
||||
return sourceType === "cbu" && controlId !== "clusters"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
{crimeTooltips.map((control) => (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="medium"
|
||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
}`}
|
||||
onClick={() => handleControlClick(control.id)}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
{crimeTooltips.map((control) => {
|
||||
const isButtonDisabled = isDisabled(control.id)
|
||||
|
||||
return (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="medium"
|
||||
className={`h-8 w-8 rounded-md ${isButtonDisabled
|
||||
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||
: activeControl === control.id
|
||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
}`}
|
||||
onClick={() => handleControlClick(control.id)}
|
||||
disabled={isButtonDisabled}
|
||||
aria-disabled={isButtonDisabled}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,15 +2,25 @@
|
|||
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
||||
import { Search, XCircle, Info, ExternalLink, Calendar, MapPin, MessageSquare, FileText, Map, FolderOpen } from 'lucide-react'
|
||||
import {
|
||||
Search,
|
||||
XCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Calendar,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Map,
|
||||
FolderOpen,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import ActionSearchBar from "@/app/_components/action-search-bar"
|
||||
import { Card } from "@/app/_components/ui/card"
|
||||
import { format } from 'date-fns'
|
||||
import { ITooltips } from "./tooltips"
|
||||
import { $Enums } from "@prisma/client"
|
||||
import { format } from "date-fns"
|
||||
import type { ITooltips } from "./tooltips"
|
||||
|
||||
// Define types based on the crime data structure
|
||||
interface ICrimeIncident {
|
||||
|
@ -19,10 +29,10 @@ interface ICrimeIncident {
|
|||
description: string
|
||||
status: string
|
||||
locations: {
|
||||
address: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
},
|
||||
address: string
|
||||
longitude: number
|
||||
latitude: number
|
||||
}
|
||||
crime_categories: {
|
||||
id: string
|
||||
name: string
|
||||
|
@ -88,9 +98,15 @@ interface SearchTooltipProps {
|
|||
onControlChange?: (controlId: ITooltips) => void
|
||||
activeControl?: string
|
||||
crimes?: ICrime[]
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export default function SearchTooltip({ onControlChange, activeControl, crimes = [] }: SearchTooltipProps) {
|
||||
export default function SearchTooltip({
|
||||
onControlChange,
|
||||
activeControl,
|
||||
crimes = [],
|
||||
sourceType = "cbt",
|
||||
}: SearchTooltipProps) {
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
|
||||
|
@ -100,237 +116,244 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
|||
const [selectedSuggestion, setSelectedSuggestion] = useState<ICrimeIncident | null>(null)
|
||||
const [showInfoBox, setShowInfoBox] = useState(false)
|
||||
|
||||
// Check if search is disabled based on source type
|
||||
const isSearchDisabled = sourceType === "cbu"
|
||||
|
||||
// Limit results to prevent performance issues
|
||||
const MAX_RESULTS = 50;
|
||||
const MAX_RESULTS = 50
|
||||
|
||||
// Extract all incidents from crimes data
|
||||
const allIncidents = crimes.flatMap(crime =>
|
||||
crime.crime_incidents.map(incident => ({
|
||||
const allIncidents = crimes.flatMap((crime) =>
|
||||
crime.crime_incidents.map((incident) => ({
|
||||
...incident,
|
||||
district: crime.districts?.name || '',
|
||||
year: crime.year,
|
||||
month: crime.month
|
||||
}))
|
||||
)
|
||||
district: crime.districts?.name || "",
|
||||
year: crime.year,
|
||||
month: crime.month,
|
||||
})),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (showSearch && searchInputRef.current) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [showSearch]);
|
||||
searchInputRef.current?.focus()
|
||||
}, 100)
|
||||
}
|
||||
}, [showSearch])
|
||||
|
||||
const handleSearchTypeSelect = (actionId: string) => {
|
||||
const selectedAction = ACTIONS.find(action => action.id === actionId);
|
||||
if (selectedAction) {
|
||||
setSelectedSearchType(actionId);
|
||||
const selectedAction = ACTIONS.find((action) => action.id === actionId)
|
||||
if (selectedAction) {
|
||||
setSelectedSearchType(actionId)
|
||||
|
||||
const prefix = selectedAction.prefix || "";
|
||||
setSearchValue(prefix);
|
||||
setIsInputValid(true);
|
||||
const prefix = selectedAction.prefix || ""
|
||||
setSearchValue(prefix)
|
||||
setIsInputValid(true)
|
||||
|
||||
// Initial suggestions based on the selected search type
|
||||
let initialSuggestions: ICrimeIncident[] = [];
|
||||
// Initial suggestions based on the selected search type
|
||||
let initialSuggestions: ICrimeIncident[] = []
|
||||
|
||||
if (actionId === 'incident_id') {
|
||||
initialSuggestions = allIncidents.slice(0, MAX_RESULTS); // Limit to 50 results initially
|
||||
} else if (actionId === 'description' || actionId === 'locations.address') {
|
||||
initialSuggestions = allIncidents.slice(0, MAX_RESULTS);
|
||||
}
|
||||
|
||||
// Set suggestions in the next tick
|
||||
setTimeout(() => {
|
||||
setSuggestions(initialSuggestions);
|
||||
}, 0);
|
||||
|
||||
// Focus and position cursor after prefix
|
||||
setTimeout(() => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
searchInputRef.current.selectionStart = prefix.length;
|
||||
searchInputRef.current.selectionEnd = prefix.length;
|
||||
}
|
||||
}, 50);
|
||||
if (actionId === "incident_id") {
|
||||
initialSuggestions = allIncidents.slice(0, MAX_RESULTS) // Limit to 50 results initially
|
||||
} else if (actionId === "description" || actionId === "locations.address") {
|
||||
initialSuggestions = allIncidents.slice(0, MAX_RESULTS)
|
||||
}
|
||||
}
|
||||
|
||||
// Set suggestions in the next tick
|
||||
setTimeout(() => {
|
||||
setSuggestions(initialSuggestions)
|
||||
}, 0)
|
||||
|
||||
// Focus and position cursor after prefix
|
||||
setTimeout(() => {
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus()
|
||||
searchInputRef.current.selectionStart = prefix.length
|
||||
searchInputRef.current.selectionEnd = prefix.length
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter suggestions based on search type and search text
|
||||
const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => {
|
||||
let filtered: ICrimeIncident[] = [];
|
||||
let filtered: ICrimeIncident[] = []
|
||||
|
||||
if (searchType === 'incident_id') {
|
||||
if (!searchText || searchText === 'CI-') {
|
||||
filtered = allIncidents.slice(0, MAX_RESULTS);
|
||||
} else {
|
||||
filtered = allIncidents.filter(item =>
|
||||
item.id.toLowerCase().includes(searchText.toLowerCase())
|
||||
).slice(0, MAX_RESULTS);
|
||||
}
|
||||
}
|
||||
else if (searchType === 'description') {
|
||||
if (!searchText) {
|
||||
filtered = allIncidents.slice(0, MAX_RESULTS);
|
||||
} else {
|
||||
filtered = allIncidents.filter(item =>
|
||||
item.description.toLowerCase().includes(searchText.toLowerCase())
|
||||
).slice(0, MAX_RESULTS);
|
||||
}
|
||||
}
|
||||
else if (searchType === 'locations.address') {
|
||||
if (!searchText) {
|
||||
filtered = allIncidents.slice(0, MAX_RESULTS);
|
||||
} else {
|
||||
filtered = allIncidents.filter(item =>
|
||||
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.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.locations.latitude !== undefined &&
|
||||
item.locations.longitude !== undefined &&
|
||||
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText)
|
||||
).slice(0, MAX_RESULTS);
|
||||
}
|
||||
if (searchType === "incident_id") {
|
||||
if (!searchText || searchText === "CI-") {
|
||||
filtered = allIncidents.slice(0, MAX_RESULTS)
|
||||
} else {
|
||||
filtered = allIncidents
|
||||
.filter((item) => item.id.toLowerCase().includes(searchText.toLowerCase()))
|
||||
.slice(0, MAX_RESULTS)
|
||||
}
|
||||
} else if (searchType === "description") {
|
||||
if (!searchText) {
|
||||
filtered = allIncidents.slice(0, MAX_RESULTS)
|
||||
} else {
|
||||
filtered = allIncidents
|
||||
.filter((item) => item.description.toLowerCase().includes(searchText.toLowerCase()))
|
||||
.slice(0, MAX_RESULTS)
|
||||
}
|
||||
} else if (searchType === "locations.address") {
|
||||
if (!searchText) {
|
||||
filtered = allIncidents.slice(0, MAX_RESULTS)
|
||||
} else {
|
||||
filtered = allIncidents
|
||||
.filter(
|
||||
(item) => 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.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.locations.latitude !== undefined &&
|
||||
item.locations.longitude !== undefined &&
|
||||
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText),
|
||||
)
|
||||
.slice(0, MAX_RESULTS)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
return filtered
|
||||
}
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
const currentSearchType = selectedSearchType ?
|
||||
ACTIONS.find(action => action.id === selectedSearchType) : null;
|
||||
const currentSearchType = selectedSearchType ? ACTIONS.find((action) => action.id === selectedSearchType) : null
|
||||
|
||||
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
|
||||
if (!value.startsWith(currentSearchType.prefix)) {
|
||||
value = currentSearchType.prefix;
|
||||
}
|
||||
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
|
||||
if (!value.startsWith(currentSearchType.prefix)) {
|
||||
value = currentSearchType.prefix
|
||||
}
|
||||
|
||||
setSearchValue(value);
|
||||
|
||||
if (currentSearchType?.regex) {
|
||||
if (!value || value === currentSearchType.prefix) {
|
||||
setIsInputValid(true);
|
||||
} else {
|
||||
setIsInputValid(currentSearchType.regex.test(value));
|
||||
}
|
||||
} else {
|
||||
setIsInputValid(true);
|
||||
}
|
||||
|
||||
if (!selectedSearchType) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter suggestions based on search input
|
||||
setSuggestions(filterSuggestions(selectedSearchType, value));
|
||||
}
|
||||
|
||||
setSearchValue(value)
|
||||
|
||||
if (currentSearchType?.regex) {
|
||||
if (!value || value === currentSearchType.prefix) {
|
||||
setIsInputValid(true)
|
||||
} else {
|
||||
setIsInputValid(currentSearchType.regex.test(value))
|
||||
}
|
||||
} else {
|
||||
setIsInputValid(true)
|
||||
}
|
||||
|
||||
if (!selectedSearchType) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
// Filter suggestions based on search input
|
||||
setSuggestions(filterSuggestions(selectedSearchType, value))
|
||||
}
|
||||
|
||||
const handleClearSearchType = () => {
|
||||
setSelectedSearchType(null);
|
||||
setSearchValue("");
|
||||
setSuggestions([]);
|
||||
if (searchInputRef.current) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
setSelectedSearchType(null)
|
||||
setSearchValue("")
|
||||
setSuggestions([])
|
||||
if (searchInputRef.current) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggestionSelect = (incident: ICrimeIncident) => {
|
||||
setSearchValue(incident.id);
|
||||
setSuggestions([]);
|
||||
setSelectedSuggestion(incident);
|
||||
setShowInfoBox(true);
|
||||
};
|
||||
setSearchValue(incident.id)
|
||||
setSuggestions([])
|
||||
setSelectedSuggestion(incident)
|
||||
setShowInfoBox(true)
|
||||
}
|
||||
|
||||
const handleFlyToIncident = () => {
|
||||
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return;
|
||||
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return
|
||||
|
||||
// Dispatch mapbox_fly_to event to the main map canvas only
|
||||
const flyToMapEvent = new CustomEvent('mapbox_fly_to', {
|
||||
// Dispatch mapbox_fly_to event to the main map canvas only
|
||||
const flyToMapEvent = new CustomEvent("mapbox_fly_to", {
|
||||
detail: {
|
||||
longitude: selectedSuggestion.locations.longitude,
|
||||
latitude: selectedSuggestion.locations.latitude,
|
||||
zoom: 15,
|
||||
bearing: 0,
|
||||
pitch: 45,
|
||||
duration: 2000,
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
|
||||
// Find the main map canvas and dispatch event there
|
||||
const mapCanvas = document.querySelector(".mapboxgl-canvas")
|
||||
if (mapCanvas) {
|
||||
mapCanvas.dispatchEvent(flyToMapEvent)
|
||||
}
|
||||
|
||||
// Wait for the fly animation to complete before showing the popup
|
||||
setTimeout(() => {
|
||||
// Then trigger the incident_click event to show the popup
|
||||
const incidentEvent = new CustomEvent("incident_click", {
|
||||
detail: {
|
||||
id: selectedSuggestion.id,
|
||||
longitude: selectedSuggestion.locations.longitude,
|
||||
latitude: selectedSuggestion.locations.latitude,
|
||||
zoom: 15,
|
||||
bearing: 0,
|
||||
pitch: 45,
|
||||
duration: 2000,
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
description: selectedSuggestion.description,
|
||||
status: selectedSuggestion.status,
|
||||
timestamp: selectedSuggestion.timestamp,
|
||||
crime_categories: selectedSuggestion.crime_categories,
|
||||
},
|
||||
bubbles: true,
|
||||
})
|
||||
|
||||
// Find the main map canvas and dispatch event there
|
||||
const mapCanvas = document.querySelector('.mapboxgl-canvas');
|
||||
if (mapCanvas) {
|
||||
mapCanvas.dispatchEvent(flyToMapEvent);
|
||||
}
|
||||
document.dispatchEvent(incidentEvent)
|
||||
}, 2100) // Slightly longer than the fly animation duration
|
||||
|
||||
// Wait for the fly animation to complete before showing the popup
|
||||
setTimeout(() => {
|
||||
// Then trigger the incident_click event to show the popup
|
||||
const incidentEvent = new CustomEvent('incident_click', {
|
||||
detail: {
|
||||
id: selectedSuggestion.id,
|
||||
longitude: selectedSuggestion.locations.longitude,
|
||||
latitude: selectedSuggestion.locations.latitude,
|
||||
description: selectedSuggestion.description,
|
||||
status: selectedSuggestion.status,
|
||||
timestamp: selectedSuggestion.timestamp,
|
||||
crime_categories: selectedSuggestion.crime_categories
|
||||
},
|
||||
bubbles: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(incidentEvent);
|
||||
}, 2100); // Slightly longer than the fly animation duration
|
||||
|
||||
setShowInfoBox(false);
|
||||
setSelectedSuggestion(null);
|
||||
toggleSearch();
|
||||
};
|
||||
setShowInfoBox(false)
|
||||
setSelectedSuggestion(null)
|
||||
toggleSearch()
|
||||
}
|
||||
|
||||
const handleCloseInfoBox = () => {
|
||||
setShowInfoBox(false);
|
||||
setSelectedSuggestion(null);
|
||||
setShowInfoBox(false)
|
||||
setSelectedSuggestion(null)
|
||||
|
||||
// Restore original suggestions
|
||||
if (selectedSearchType) {
|
||||
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue);
|
||||
setSuggestions(initialSuggestions);
|
||||
}
|
||||
};
|
||||
// Restore original suggestions
|
||||
if (selectedSearchType) {
|
||||
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue)
|
||||
setSuggestions(initialSuggestions)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSearch = () => {
|
||||
setShowSearch(!showSearch)
|
||||
if (!showSearch && onControlChange) {
|
||||
onControlChange("search" as ITooltips)
|
||||
setSelectedSearchType(null);
|
||||
setSearchValue("");
|
||||
setSuggestions([]);
|
||||
}
|
||||
}
|
||||
if (isSearchDisabled) return
|
||||
|
||||
setShowSearch(!showSearch)
|
||||
if (!showSearch && onControlChange) {
|
||||
onControlChange("search" as ITooltips)
|
||||
setSelectedSearchType(null)
|
||||
setSearchValue("")
|
||||
setSuggestions([])
|
||||
}
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formatIncidentDate = (incident: ICrimeIncident) => {
|
||||
try {
|
||||
if (incident.timestamp) {
|
||||
return format(new Date(incident.timestamp), 'PPP p');
|
||||
}
|
||||
return 'N/A';
|
||||
} catch (error) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
};
|
||||
return format(new Date(incident.timestamp), "PPP p")
|
||||
}
|
||||
return "N/A"
|
||||
} catch (error) {
|
||||
return "Invalid date"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -341,223 +364,222 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
|||
<Button
|
||||
variant={showSearch ? "default" : "ghost"}
|
||||
size="medium"
|
||||
className={`h-8 w-8 rounded-md ${showSearch
|
||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
}`}
|
||||
onClick={toggleSearch}
|
||||
>
|
||||
<Search size={20} />
|
||||
<span className="sr-only">Search Incidents</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Search Incidents</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
className={`h-8 w-8 rounded-md ${isSearchDisabled
|
||||
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||
: showSearch
|
||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||
}`}
|
||||
onClick={toggleSearch}
|
||||
disabled={isSearchDisabled}
|
||||
aria-disabled={isSearchDisabled}
|
||||
>
|
||||
<Search size={20} />
|
||||
<span className="sr-only">Search Incidents</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{isSearchDisabled ? "Not available for CBU data" : "Search Incidents"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showSearch && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
onClick={toggleSearch}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{showSearch && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
onClick={toggleSearch}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed top-1/4 left-1/4 transform -translate-x-1/4 -translate-y-1/4 z-50 w-full max-w-lg sm:max-w-xl md:max-w-3xl"
|
||||
>
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-lg font-medium">Search Incidents</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background"
|
||||
onClick={toggleSearch}
|
||||
>
|
||||
<XCircle size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed top-1/4 left-1/4 transform -translate-x-1/4 -translate-y-1/4 z-50 w-full max-w-lg sm:max-w-xl md:max-w-3xl"
|
||||
>
|
||||
<div className="bg-background border border-border rounded-lg shadow-xl p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-lg font-medium">Search Incidents</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background"
|
||||
onClick={toggleSearch}
|
||||
>
|
||||
<XCircle size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!showInfoBox ? (
|
||||
<>
|
||||
<ActionSearchBar
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
isFloating
|
||||
defaultActions={false}
|
||||
actions={ACTIONS}
|
||||
onActionSelect={handleSearchTypeSelect}
|
||||
onClearAction={handleClearSearchType}
|
||||
activeActionId={selectedSearchType}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={selectedSearchType ?
|
||||
ACTIONS.find(a => a.id === selectedSearchType)?.placeholder :
|
||||
"Select a search type..."}
|
||||
inputClassName={!isInputValid ?
|
||||
"border-destructive focus-visible:ring-destructive bg-destructive/50" : ""}
|
||||
/>
|
||||
{!showInfoBox ? (
|
||||
<>
|
||||
<ActionSearchBar
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
isFloating
|
||||
defaultActions={false}
|
||||
actions={ACTIONS}
|
||||
onActionSelect={handleSearchTypeSelect}
|
||||
onClearAction={handleClearSearchType}
|
||||
activeActionId={selectedSearchType}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={
|
||||
selectedSearchType
|
||||
? ACTIONS.find((a) => a.id === selectedSearchType)?.placeholder
|
||||
: "Select a search type..."
|
||||
}
|
||||
inputClassName={
|
||||
!isInputValid ? "border-destructive focus-visible:ring-destructive bg-destructive/50" : ""
|
||||
}
|
||||
/>
|
||||
|
||||
{!isInputValid && selectedSearchType && (
|
||||
<div className="mt-1 text-xs text-destructive">
|
||||
Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description}
|
||||
</div>
|
||||
)}
|
||||
{!isInputValid && selectedSearchType && (
|
||||
<div className="mt-1 text-xs text-destructive">
|
||||
Invalid format. {ACTIONS.find((a) => a.id === selectedSearchType)?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(suggestions.length > 0 && selectedSearchType) && (
|
||||
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
|
||||
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{suggestions.length} results found
|
||||
{suggestions.length === 50 && " (showing top 50)"}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="py-1">
|
||||
{suggestions.map((incident, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between"
|
||||
onClick={() => handleSuggestionSelect(incident)}
|
||||
>
|
||||
<span className="font-medium">{incident.id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedSearchType === 'incident_id' ? (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.description}
|
||||
</span>
|
||||
) : selectedSearchType === 'coordinates' ? (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.locations.latitude}, {incident.locations.longitude} - {incident.description}
|
||||
</span>
|
||||
) : selectedSearchType === 'locations.address' ? (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.locations.address || 'N/A'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.description}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSuggestionSelect(incident);
|
||||
}}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{suggestions.length > 0 && selectedSearchType && (
|
||||
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
|
||||
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{suggestions.length} results found
|
||||
{suggestions.length === 50 && " (showing top 50)"}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="py-1">
|
||||
{suggestions.map((incident, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between"
|
||||
onClick={() => handleSuggestionSelect(incident)}
|
||||
>
|
||||
<span className="font-medium">{incident.id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedSearchType === "incident_id" ? (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.description}
|
||||
</span>
|
||||
) : selectedSearchType === "coordinates" ? (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.locations.latitude}, {incident.locations.longitude} -{" "}
|
||||
{incident.description}
|
||||
</span>
|
||||
) : selectedSearchType === "locations.address" ? (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.locations.address || "N/A"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
|
||||
{incident.description}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleSuggestionSelect(incident)
|
||||
}}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedSearchType && searchValue.length > (ACTIONS.find(a => a.id === selectedSearchType)?.prefix?.length || 0) &&
|
||||
suggestions.length === 0 && (
|
||||
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
|
||||
<p className="text-sm text-muted-foreground">No matching incidents found</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedSearchType &&
|
||||
searchValue.length > (ACTIONS.find((a) => a.id === selectedSearchType)?.prefix?.length || 0) &&
|
||||
suggestions.length === 0 && (
|
||||
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
|
||||
<p className="text-sm text-muted-foreground">No matching incidents found</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 px-3 py-2 border-t border-border bg-muted/30 rounded-b-md">
|
||||
<p className="flex items-center text-sm text-muted-foreground">
|
||||
{selectedSearchType ? (
|
||||
<>
|
||||
<span className="mr-1">{ACTIONS.find(a => a.id === selectedSearchType)?.icon}</span>
|
||||
<span>
|
||||
{ACTIONS.find(a => a.id === selectedSearchType)?.description}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>
|
||||
Select a search type and enter your search criteria
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-4 border border-border">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
|
||||
</div>
|
||||
<div className="mt-3 px-3 py-2 border-t border-border bg-muted/30 rounded-b-md">
|
||||
<p className="flex items-center text-sm text-muted-foreground">
|
||||
{selectedSearchType ? (
|
||||
<>
|
||||
<span className="mr-1">{ACTIONS.find((a) => a.id === selectedSearchType)?.icon}</span>
|
||||
<span>{ACTIONS.find((a) => a.id === selectedSearchType)?.description}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Select a search type and enter your search criteria</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-4 border border-border">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
|
||||
</div>
|
||||
|
||||
{selectedSuggestion && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
|
||||
<Info className="h-4 w-4 mt-1 text-muted-foreground" />
|
||||
<p className="text-sm">{selectedSuggestion.description}</p>
|
||||
</div>
|
||||
{selectedSuggestion && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
|
||||
<Info className="h-4 w-4 mt-1 text-muted-foreground" />
|
||||
<p className="text-sm">{selectedSuggestion.description}</p>
|
||||
</div>
|
||||
|
||||
{selectedSuggestion.timestamp && (
|
||||
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
|
||||
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
|
||||
<p className="text-sm">
|
||||
{formatIncidentDate(selectedSuggestion)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedSuggestion.timestamp && (
|
||||
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
|
||||
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
|
||||
<p className="text-sm">{formatIncidentDate(selectedSuggestion)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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.locations.address}</p>
|
||||
</div>
|
||||
)}
|
||||
{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.locations.address}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Category</p>
|
||||
<p className="text-sm">{selectedSuggestion.crime_categories?.name || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Status</p>
|
||||
<p className="text-sm">{selectedSuggestion.status || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Category</p>
|
||||
<p className="text-sm">{selectedSuggestion.crime_categories?.name || "N/A"}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground font-medium">Status</p>
|
||||
<p className="text-sm">{selectedSuggestion.status || "N/A"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCloseInfoBox}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleFlyToIncident}
|
||||
disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>Fly to Incident</span>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
|
||||
<Button variant="outline" size="sm" onClick={handleCloseInfoBox}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleFlyToIncident}
|
||||
disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>Fly to Incident</span>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import { useRef, useState } from "react"
|
|||
import CrimeTooltips from "./crime-tooltips"
|
||||
import AdditionalTooltips from "./additional-tooltips"
|
||||
import SearchTooltip from "./search-control"
|
||||
import { ReactNode } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
// Define the possible control IDs for the crime map
|
||||
export type ITooltips =
|
||||
|
@ -24,19 +24,22 @@ export type ITooltips =
|
|||
| "alerts"
|
||||
| "layers"
|
||||
| "evidence"
|
||||
| "arrests";
|
||||
| "arrests"
|
||||
|
||||
// Map tools type definition
|
||||
export interface IMapTools {
|
||||
id: ITooltips;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
description?: string;
|
||||
id: ITooltips
|
||||
label: string
|
||||
icon: ReactNode
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
onControlChange?: (controlId: ITooltips) => void
|
||||
activeControl?: string
|
||||
selectedSourceType: string
|
||||
setSelectedSourceType: (sourceType: string) => void
|
||||
availableSourceTypes: string[] // This must be string[] to match with API response
|
||||
selectedYear: number
|
||||
setSelectedYear: (year: number) => void
|
||||
selectedMonth: number | "all"
|
||||
|
@ -51,15 +54,18 @@ interface TooltipProps {
|
|||
export default function Tooltips({
|
||||
onControlChange,
|
||||
activeControl,
|
||||
selectedSourceType,
|
||||
setSelectedSourceType,
|
||||
availableSourceTypes = [],
|
||||
selectedYear,
|
||||
setSelectedYear,
|
||||
selectedMonth,
|
||||
setSelectedMonth,
|
||||
selectedCategory,
|
||||
setSelectedCategory,
|
||||
availableYears = [2022, 2023, 2024],
|
||||
availableYears = [],
|
||||
categories = [],
|
||||
crimes = []
|
||||
crimes = [],
|
||||
}: TooltipProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
@ -68,29 +74,37 @@ export default function Tooltips({
|
|||
<div ref={containerRef} className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Crime Tooltips Component */}
|
||||
<CrimeTooltips activeControl={activeControl} onControlChange={onControlChange} />
|
||||
<CrimeTooltips
|
||||
activeControl={activeControl}
|
||||
onControlChange={onControlChange}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
|
||||
{/* Additional Tooltips Component */}
|
||||
<AdditionalTooltips
|
||||
activeControl={activeControl}
|
||||
onControlChange={onControlChange}
|
||||
selectedYear={selectedYear}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
availableYears={availableYears}
|
||||
categories={categories}
|
||||
/>
|
||||
{/* Additional Tooltips Component */}
|
||||
<AdditionalTooltips
|
||||
activeControl={activeControl}
|
||||
onControlChange={onControlChange}
|
||||
selectedSourceType={selectedSourceType}
|
||||
setSelectedSourceType={setSelectedSourceType}
|
||||
availableSourceTypes={availableSourceTypes}
|
||||
selectedYear={selectedYear}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
availableYears={availableYears}
|
||||
categories={categories}
|
||||
/>
|
||||
|
||||
{/* Search Control Component */}
|
||||
<SearchTooltip
|
||||
activeControl={activeControl}
|
||||
onControlChange={onControlChange}
|
||||
crimes={crimes} // Pass crimes data here
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
{/* Search Control Component */}
|
||||
<SearchTooltip
|
||||
activeControl={activeControl}
|
||||
onControlChange={onControlChange}
|
||||
crimes={crimes}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import { Overlay } from "./overlay"
|
|||
import MapLegend from "./legends/map-legend"
|
||||
import UnitsLegend from "./legends/units-legend"
|
||||
import TimelineLegend from "./legends/timeline-legend"
|
||||
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes, useGetCrimeTypes, useGetRecentIncidents } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||
import MapSelectors from "./controls/map-selector"
|
||||
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
|
@ -29,10 +29,11 @@ export default function CrimeMap() {
|
|||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||
const [showLegend, setShowLegend] = useState<boolean>(true)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
||||
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
|
||||
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu")
|
||||
const [selectedYear, setSelectedYear] = useState<number>(2024)
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
||||
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
||||
const [yearProgress, setYearProgress] = useState(0)
|
||||
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
|
||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
||||
|
@ -48,6 +49,8 @@ export default function CrimeMap() {
|
|||
|
||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||
|
||||
const { data: availableSourceTypes, isLoading: isTypeLoading } = useGetCrimeTypes()
|
||||
|
||||
const {
|
||||
data: availableYears,
|
||||
isLoading: isYearsLoading,
|
||||
|
@ -68,6 +71,8 @@ export default function CrimeMap() {
|
|||
|
||||
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
|
||||
|
||||
const { data: recentIncidents } = useGetRecentIncidents()
|
||||
|
||||
useEffect(() => {
|
||||
if (activeControl === "heatmap" || activeControl === "timeline") {
|
||||
setUseAllYears(true);
|
||||
|
@ -78,20 +83,25 @@ export default function CrimeMap() {
|
|||
}
|
||||
}, [activeControl]);
|
||||
|
||||
const filteredByYearAndMonth = useMemo(() => {
|
||||
const crimesBySourceType = useMemo(() => {
|
||||
if (!crimes) return [];
|
||||
return crimes.filter(crime => crime.source_type === selectedSourceType);
|
||||
}, [crimes, selectedSourceType]);
|
||||
|
||||
const filteredByYearAndMonth = useMemo(() => {
|
||||
if (!crimesBySourceType) return [];
|
||||
|
||||
if (useAllYears) {
|
||||
if (useAllMonths) {
|
||||
return crimes;
|
||||
return crimesBySourceType;
|
||||
} else {
|
||||
return crimes.filter((crime) => {
|
||||
return crimesBySourceType.filter((crime) => {
|
||||
return selectedMonth === "all" ? true : crime.month === selectedMonth;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return crimes.filter((crime) => {
|
||||
return crimesBySourceType.filter((crime) => {
|
||||
const yearMatch = crime.year === selectedYear;
|
||||
|
||||
if (selectedMonth === "all" || useAllMonths) {
|
||||
|
@ -100,7 +110,7 @@ export default function CrimeMap() {
|
|||
return yearMatch && crime.month === selectedMonth;
|
||||
}
|
||||
});
|
||||
}, [crimes, selectedYear, selectedMonth, useAllYears, useAllMonths]);
|
||||
}, [crimesBySourceType, selectedYear, selectedMonth, useAllYears, useAllMonths]);
|
||||
|
||||
const filteredCrimes = useMemo(() => {
|
||||
if (!filteredByYearAndMonth) return []
|
||||
|
@ -119,6 +129,32 @@ export default function CrimeMap() {
|
|||
})
|
||||
}, [filteredByYearAndMonth, selectedCategory])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSourceType === "cbu") {
|
||||
if (activeControl !== "clusters" && activeControl !== "reports" &&
|
||||
activeControl !== "layers" && activeControl !== "search" &&
|
||||
activeControl !== "alerts") {
|
||||
setActiveControl("clusters");
|
||||
setShowClusters(true);
|
||||
setShowUnclustered(false);
|
||||
}
|
||||
}
|
||||
}, [selectedSourceType, activeControl]);
|
||||
|
||||
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
||||
setSelectedSourceType(sourceType);
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
setActiveControl("clusters");
|
||||
setShowClusters(true);
|
||||
setShowUnclustered(false);
|
||||
} else {
|
||||
setActiveControl("incidents");
|
||||
setShowUnclustered(true);
|
||||
setShowClusters(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
|
||||
setSelectedYear(year)
|
||||
setSelectedMonth(month)
|
||||
|
@ -155,6 +191,11 @@ export default function CrimeMap() {
|
|||
}
|
||||
|
||||
const handleControlChange = (controlId: ITooltips) => {
|
||||
if (selectedSourceType === "cbu" &&
|
||||
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveControl(controlId);
|
||||
|
||||
if (controlId === "clusters") {
|
||||
|
@ -187,7 +228,6 @@ export default function CrimeMap() {
|
|||
setUseAllMonths(false);
|
||||
}
|
||||
|
||||
// Enable EWS in all modes for demo purposes
|
||||
setShowEWS(true);
|
||||
}
|
||||
|
||||
|
@ -222,7 +262,7 @@ export default function CrimeMap() {
|
|||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mapbox-container overlay-bg vertical-reveal relative h-[600px]" ref={mapContainerRef}>
|
||||
<div className="mapbox-container overlay-bg relative h-[600px]" ref={mapContainerRef}>
|
||||
<div className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
||||
|
@ -238,6 +278,8 @@ export default function CrimeMap() {
|
|||
activeControl={activeControl}
|
||||
useAllData={useAllYears}
|
||||
showEWS={showEWS}
|
||||
recentIncidents={recentIncidents || []}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
|
||||
{isFullscreen && (
|
||||
|
@ -246,6 +288,9 @@ export default function CrimeMap() {
|
|||
<Tooltips
|
||||
activeControl={activeControl}
|
||||
onControlChange={handleControlChange}
|
||||
selectedSourceType={selectedSourceType}
|
||||
setSelectedSourceType={handleSourceTypeChange}
|
||||
availableSourceTypes={availableSourceTypes || []}
|
||||
selectedYear={selectedYear}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
|
@ -264,6 +309,7 @@ export default function CrimeMap() {
|
|||
selectedCategory={selectedCategory}
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
sourceType={selectedSourceType} // Pass the sourceType
|
||||
/>
|
||||
{isFullscreen && (
|
||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useEffect, useCallback } from "react"
|
||||
|
||||
import type mapboxgl from "mapbox-gl"
|
||||
import mapboxgl from "mapbox-gl"
|
||||
import type { GeoJSON } from "geojson"
|
||||
import type { IClusterLayerProps } from "@/app/_utils/types/map"
|
||||
import { extractCrimeIncidents } from "@/app/_utils/map"
|
||||
|
@ -10,6 +10,7 @@ import { extractCrimeIncidents } from "@/app/_utils/map"
|
|||
interface ExtendedClusterLayerProps extends IClusterLayerProps {
|
||||
clusteringEnabled?: boolean
|
||||
showClusters?: boolean
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export default function ClusterLayer({
|
||||
|
@ -20,6 +21,7 @@ export default function ClusterLayer({
|
|||
focusedDistrictId,
|
||||
clusteringEnabled = false,
|
||||
showClusters = false,
|
||||
sourceType = "cbt",
|
||||
}: ExtendedClusterLayerProps) {
|
||||
const handleClusterClick = useCallback(
|
||||
(e: any) => {
|
||||
|
@ -35,7 +37,6 @@ export default function ClusterLayer({
|
|||
const clusterId: number = features[0].properties?.cluster_id as number
|
||||
|
||||
try {
|
||||
// Get the expanded zoom level for this cluster
|
||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
|
||||
clusterId,
|
||||
(err, zoom) => {
|
||||
|
@ -46,7 +47,6 @@ export default function ClusterLayer({
|
|||
|
||||
const coordinates = (features[0].geometry as any).coordinates
|
||||
|
||||
// Explicitly fly to the cluster location
|
||||
map.flyTo({
|
||||
center: coordinates,
|
||||
zoom: zoom ?? 12,
|
||||
|
@ -80,13 +80,38 @@ export default function ClusterLayer({
|
|||
}
|
||||
|
||||
if (!map.getSource("crime-incidents")) {
|
||||
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
|
||||
let features: GeoJSON.Feature[] = []
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
features = crimes.map(crime => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
district_id: crime.district_id,
|
||||
district_name: crime.districts ? crime.districts.name : "Unknown",
|
||||
crime_count: crime.number_of_crime || 0,
|
||||
level: crime.level,
|
||||
category: filterCategory !== "all" ? filterCategory : "All",
|
||||
year: crime.year,
|
||||
month: crime.month,
|
||||
isCBU: true,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [
|
||||
crime.districts?.geographics?.[0]?.longitude || 0,
|
||||
crime.districts?.geographics?.[0]?.latitude || 0,
|
||||
],
|
||||
},
|
||||
})) as GeoJSON.Feature[]
|
||||
} else {
|
||||
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
|
||||
}
|
||||
|
||||
map.addSource("crime-incidents", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: allIncidents as GeoJSON.Feature[],
|
||||
features: features,
|
||||
},
|
||||
cluster: clusteringEnabled,
|
||||
clusterMaxZoom: 14,
|
||||
|
@ -131,6 +156,103 @@ export default function ClusterLayer({
|
|||
})
|
||||
}
|
||||
|
||||
if (sourceType === "cbu" && !map.getLayer("crime-points")) {
|
||||
map.addLayer({
|
||||
id: "crime-points",
|
||||
type: "circle",
|
||||
source: "crime-incidents",
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
paint: {
|
||||
"circle-radius": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
8,
|
||||
["interpolate", ["linear"], ["get", "crime_count"], 0, 5, 100, 20],
|
||||
12,
|
||||
["interpolate", ["linear"], ["get", "crime_count"], 0, 8, 100, 30],
|
||||
],
|
||||
"circle-color": [
|
||||
"match",
|
||||
["get", "level"],
|
||||
"low",
|
||||
"#47B39C",
|
||||
"medium",
|
||||
"#FFC154",
|
||||
"high",
|
||||
"#EC6B56",
|
||||
"#888888",
|
||||
],
|
||||
"circle-opacity": 0.7,
|
||||
"circle-stroke-width": 1,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
},
|
||||
layout: {
|
||||
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
},
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: "crime-count-labels",
|
||||
type: "symbol",
|
||||
source: "crime-incidents",
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
layout: {
|
||||
"text-field": "{crime_count}",
|
||||
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
||||
"text-size": 12,
|
||||
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#ffffff",
|
||||
},
|
||||
})
|
||||
|
||||
map.on("mouseenter", "crime-points", () => {
|
||||
map.getCanvas().style.cursor = "pointer"
|
||||
})
|
||||
|
||||
map.on("mouseleave", "crime-points", () => {
|
||||
map.getCanvas().style.cursor = ""
|
||||
})
|
||||
|
||||
const handleCrimePointClick = (e: any) => {
|
||||
if (!map) return
|
||||
|
||||
e.originalEvent.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] })
|
||||
|
||||
if (features.length > 0) {
|
||||
const feature = features[0]
|
||||
const props = feature.properties
|
||||
const coordinates = (feature.geometry as any).coordinates.slice()
|
||||
|
||||
if (props) {
|
||||
if (props) {
|
||||
const popupHTML = `
|
||||
<div class="p-3">
|
||||
<h3 class="font-bold">${props.district_name}</h3>
|
||||
<div class="mt-2">
|
||||
<p>Total Crimes: <b>${props.crime_count}</b></p>
|
||||
<p>Crime Level: <b>${props.level}</b></p>
|
||||
<p>Year: ${props.year} - Month: ${props.month}</p>
|
||||
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.off("click", "crime-points", handleCrimePointClick)
|
||||
map.on("click", "crime-points", handleCrimePointClick)
|
||||
}
|
||||
|
||||
map.on("mouseenter", "clusters", () => {
|
||||
map.getCanvas().style.cursor = "pointer"
|
||||
})
|
||||
|
@ -139,36 +261,66 @@ export default function ClusterLayer({
|
|||
map.getCanvas().style.cursor = ""
|
||||
})
|
||||
|
||||
// Remove and re-add click handler to avoid duplicates
|
||||
map.off("click", "clusters", handleClusterClick)
|
||||
map.on("click", "clusters", handleClusterClick)
|
||||
} else {
|
||||
// Update source clustering option
|
||||
try {
|
||||
// We need to recreate the source if we're changing the clustering option
|
||||
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource
|
||||
const data = (currentSource as any)._data // Get current data
|
||||
const data = (currentSource as any)._data
|
||||
|
||||
// If clustering state has changed, recreate the source
|
||||
const existingClusterState = (currentSource as any).options?.cluster
|
||||
if (existingClusterState !== clusteringEnabled) {
|
||||
// Remove existing layers that use this source
|
||||
const sourceTypeChanged =
|
||||
data.features.length > 0 &&
|
||||
((sourceType === "cbu" && !data.features[0].properties.isCBU) ||
|
||||
(sourceType !== "cbu" && data.features[0].properties.isCBU))
|
||||
|
||||
if (existingClusterState !== clusteringEnabled || sourceTypeChanged) {
|
||||
if (map.getLayer("clusters")) map.removeLayer("clusters")
|
||||
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count")
|
||||
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point")
|
||||
if (map.getLayer("crime-points")) map.removeLayer("crime-points")
|
||||
if (map.getLayer("crime-count-labels")) map.removeLayer("crime-count-labels")
|
||||
|
||||
// Remove and recreate source with new clustering setting
|
||||
map.removeSource("crime-incidents")
|
||||
|
||||
let features: GeoJSON.Feature[] = []
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
features = crimes.map(crime => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
district_id: crime.district_id,
|
||||
district_name: crime.districts ? crime.districts.name : "Unknown",
|
||||
crime_count: crime.number_of_crime || 0,
|
||||
level: crime.level,
|
||||
category: filterCategory !== "all" ? filterCategory : "All",
|
||||
year: crime.year,
|
||||
month: crime.month,
|
||||
isCBU: true,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [
|
||||
crime.districts?.geographics?.[0]?.longitude || 0,
|
||||
crime.districts?.geographics?.[0]?.latitude || 0,
|
||||
],
|
||||
},
|
||||
})) as GeoJSON.Feature[]
|
||||
} else {
|
||||
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
|
||||
}
|
||||
|
||||
map.addSource("crime-incidents", {
|
||||
type: "geojson",
|
||||
data: data,
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: features,
|
||||
},
|
||||
cluster: clusteringEnabled,
|
||||
clusterMaxZoom: 14,
|
||||
clusterRadius: 50,
|
||||
})
|
||||
|
||||
// Re-add the layers
|
||||
if (!map.getLayer("clusters")) {
|
||||
map.addLayer(
|
||||
{
|
||||
|
@ -206,12 +358,108 @@ export default function ClusterLayer({
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
if (!map.getLayer("crime-points")) {
|
||||
map.addLayer({
|
||||
id: "crime-points",
|
||||
type: "circle",
|
||||
source: "crime-incidents",
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
paint: {
|
||||
"circle-radius": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
8,
|
||||
["interpolate", ["linear"], ["get", "crime_count"], 0, 5, 100, 20],
|
||||
12,
|
||||
["interpolate", ["linear"], ["get", "crime_count"], 0, 8, 100, 30],
|
||||
],
|
||||
"circle-color": [
|
||||
"match",
|
||||
["get", "level"],
|
||||
"low",
|
||||
"#47B39C",
|
||||
"medium",
|
||||
"#FFC154",
|
||||
"high",
|
||||
"#EC6B56",
|
||||
"#888888",
|
||||
],
|
||||
"circle-opacity": 0.7,
|
||||
"circle-stroke-width": 1,
|
||||
"circle-stroke-color": "#ffffff",
|
||||
},
|
||||
layout: {
|
||||
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
},
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: "crime-count-labels",
|
||||
type: "symbol",
|
||||
source: "crime-incidents",
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
layout: {
|
||||
"text-field": "{crime_count}",
|
||||
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
||||
"text-size": 12,
|
||||
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#ffffff",
|
||||
},
|
||||
})
|
||||
|
||||
map.on("mouseenter", "crime-points", () => {
|
||||
map.getCanvas().style.cursor = "pointer"
|
||||
})
|
||||
|
||||
map.on("mouseleave", "crime-points", () => {
|
||||
map.getCanvas().style.cursor = ""
|
||||
})
|
||||
|
||||
const handleCrimePointClick = (e: any) => {
|
||||
if (!map) return
|
||||
|
||||
e.originalEvent.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] })
|
||||
|
||||
if (features.length > 0) {
|
||||
const feature = features[0]
|
||||
const props = feature.properties
|
||||
const coordinates = (feature.geometry as any).coordinates.slice()
|
||||
|
||||
if (props) {
|
||||
const popupHTML = `
|
||||
<div class="p-3">
|
||||
<h3 class="font-bold">${props.district_name}</h3>
|
||||
<div class="mt-2">
|
||||
<p>Total Crimes: <b>${props.crime_count}</b></p>
|
||||
<p>Crime Level: <b>${props.level}</b></p>
|
||||
<p>Year: ${props.year} - Month: ${props.month}</p>
|
||||
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.off("click", "crime-points", handleCrimePointClick)
|
||||
map.on("click", "crime-points", handleCrimePointClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating cluster source:", error)
|
||||
}
|
||||
|
||||
// Update visibility based on focused district and showClusters flag
|
||||
if (map.getLayer("clusters")) {
|
||||
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
||||
}
|
||||
|
@ -224,7 +472,19 @@ export default function ClusterLayer({
|
|||
)
|
||||
}
|
||||
|
||||
// Update the cluster click handler
|
||||
if (sourceType === "cbu" && map.getLayer("crime-points")) {
|
||||
map.setLayoutProperty(
|
||||
"crime-points",
|
||||
"visibility",
|
||||
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
)
|
||||
map.setLayoutProperty(
|
||||
"crime-count-labels",
|
||||
"visibility",
|
||||
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
)
|
||||
}
|
||||
|
||||
map.off("click", "clusters", handleClusterClick)
|
||||
map.on("click", "clusters", handleClusterClick)
|
||||
}
|
||||
|
@ -242,36 +502,150 @@ export default function ClusterLayer({
|
|||
return () => {
|
||||
if (map) {
|
||||
map.off("click", "clusters", handleClusterClick)
|
||||
if (sourceType === "cbu" && map.getLayer("crime-points")) {
|
||||
// Define properly typed event handlers
|
||||
const crimePointsMouseEnter = function () {
|
||||
if (map && map.getCanvas()) {
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
}
|
||||
};
|
||||
|
||||
const crimePointsMouseLeave = function () {
|
||||
if (map && map.getCanvas()) {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
const crimePointsClick = function (e: mapboxgl.MapMouseEvent) {
|
||||
if (!map) return;
|
||||
|
||||
e.originalEvent.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] });
|
||||
|
||||
if (features.length > 0) {
|
||||
const feature = features[0];
|
||||
const props = feature.properties;
|
||||
const coordinates = (feature.geometry as any).coordinates.slice();
|
||||
|
||||
if (props) {
|
||||
const popupHTML = `
|
||||
<div class="p-3">
|
||||
<h3 class="font-bold">${props.district_name}</h3>
|
||||
<div class="mt-2">
|
||||
<p>Total Crimes: <b>${props.crime_count}</b></p>
|
||||
<p>Crime Level: <b>${props.level}</b></p>
|
||||
<p>Year: ${props.year} - Month: ${props.month}</p>
|
||||
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
new mapboxgl.Popup()
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(popupHTML)
|
||||
.addTo(map);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Remove event listeners with properly typed handlers
|
||||
map.off("mouseenter", "crime-points", crimePointsMouseEnter);
|
||||
map.off("mouseleave", "crime-points", crimePointsMouseLeave);
|
||||
map.off("click", "crime-points", crimePointsClick);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick, clusteringEnabled, showClusters])
|
||||
}, [
|
||||
map,
|
||||
visible,
|
||||
crimes,
|
||||
filterCategory,
|
||||
focusedDistrictId,
|
||||
handleClusterClick,
|
||||
clusteringEnabled,
|
||||
showClusters,
|
||||
sourceType,
|
||||
])
|
||||
|
||||
// Update crime incidents data when filters change
|
||||
useEffect(() => {
|
||||
if (!map || !map.getSource("crime-incidents")) return
|
||||
|
||||
try {
|
||||
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
|
||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||
type: "FeatureCollection",
|
||||
features: allIncidents as GeoJSON.Feature[],
|
||||
})
|
||||
let features: GeoJSON.Feature[]
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
features = crimes.map(crime => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
district_id: crime.district_id,
|
||||
district_name: crime.districts ? crime.districts.name : "Unknown",
|
||||
crime_count: crime.number_of_crime || 0,
|
||||
level: crime.level,
|
||||
category: filterCategory !== "all" ? filterCategory : "All",
|
||||
year: crime.year,
|
||||
month: crime.month,
|
||||
isCBU: true,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [
|
||||
crime.districts?.geographics?.[0]?.longitude || 0,
|
||||
crime.districts?.geographics?.[0]?.latitude || 0,
|
||||
],
|
||||
},
|
||||
})) as GeoJSON.Feature[]
|
||||
} else {
|
||||
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
|
||||
}
|
||||
|
||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||
type: "FeatureCollection",
|
||||
features: features,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error updating incident data:", error)
|
||||
}
|
||||
}, [map, crimes, filterCategory])
|
||||
}, [map, crimes, filterCategory, sourceType])
|
||||
|
||||
// Update visibility when showClusters changes
|
||||
useEffect(() => {
|
||||
if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return
|
||||
if (!map) return
|
||||
|
||||
try {
|
||||
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
||||
map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
||||
if (map.getLayer("clusters")) {
|
||||
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
|
||||
}
|
||||
|
||||
if (map.getLayer("cluster-count")) {
|
||||
map.setLayoutProperty(
|
||||
"cluster-count",
|
||||
"visibility",
|
||||
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
)
|
||||
}
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
if (map.getLayer("crime-points")) {
|
||||
map.setLayoutProperty(
|
||||
"crime-points",
|
||||
"visibility",
|
||||
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
)
|
||||
}
|
||||
|
||||
if (map.getLayer("crime-count-labels")) {
|
||||
map.setLayoutProperty(
|
||||
"crime-count-labels",
|
||||
"visibility",
|
||||
showClusters && !focusedDistrictId ? "visible" : "none",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating cluster visibility:", error)
|
||||
}
|
||||
}, [map, showClusters, focusedDistrictId])
|
||||
}, [map, showClusters, focusedDistrictId, sourceType])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@ interface LayersProps {
|
|||
tilesetId?: string
|
||||
useAllData?: boolean
|
||||
showEWS?: boolean
|
||||
sourceType?: string
|
||||
}
|
||||
|
||||
export default function Layers({
|
||||
|
@ -90,6 +91,7 @@ export default function Layers({
|
|||
tilesetId = MAPBOX_TILESET_ID,
|
||||
useAllData = false,
|
||||
showEWS = true,
|
||||
sourceType = "cbt",
|
||||
}: LayersProps) {
|
||||
const { current: map } = useMap()
|
||||
|
||||
|
@ -416,11 +418,11 @@ export default function Layers({
|
|||
if (!visible) return null
|
||||
|
||||
const crimesVisible = activeControl === "incidents"
|
||||
const showHeatmapLayer = activeControl === "heatmap"
|
||||
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu"
|
||||
const showUnitsLayer = activeControl === "units"
|
||||
const showTimelineLayer = activeControl === "timeline"
|
||||
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
|
||||
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
|
||||
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline" && sourceType !== "cbu"
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -484,6 +486,7 @@ export default function Layers({
|
|||
focusedDistrictId={focusedDistrictId}
|
||||
clusteringEnabled={activeControl === "clusters"}
|
||||
showClusters={activeControl === "clusters"}
|
||||
sourceType={sourceType}
|
||||
/>
|
||||
|
||||
<UnclusteredPointLayer
|
||||
|
|
|
@ -1171,3 +1171,4 @@ export function generateId(
|
|||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
type Severity = "Low" | "Medium" | "High" | "Unknown";
|
||||
|
||||
export function getSeverity(crimeName: string): Severity {
|
||||
const high = [
|
||||
"Pembunuhan",
|
||||
"Perkosaan",
|
||||
"Penculikan",
|
||||
"Pembakaran",
|
||||
"Kebakaran / Meletus",
|
||||
"Curas",
|
||||
"Curanmor",
|
||||
"Lahgun Senpi/Handak/Sajam",
|
||||
"Trafficking In Person",
|
||||
];
|
||||
|
||||
const medium = [
|
||||
"Penganiayaan Berat",
|
||||
"Penganiayaan Ringan",
|
||||
"Perjudian",
|
||||
"Sumpah Palsu",
|
||||
"Pemalsuan Materai",
|
||||
"Pemalsuan Surat",
|
||||
"Perbuatan Tidak Menyenangkan",
|
||||
"Premanisme",
|
||||
"Pemerasan Dan Pengancaman",
|
||||
"Penggelapan",
|
||||
"Penipuan",
|
||||
"Pengeroyokan",
|
||||
"PKDRT",
|
||||
"Money Loudering",
|
||||
"Illegal Logging",
|
||||
"Illegal Mining",
|
||||
];
|
||||
|
||||
const low = [
|
||||
"Member Suap",
|
||||
"Menerima Suap",
|
||||
"Penghinaan",
|
||||
"Perzinahan",
|
||||
"Terhadap Ketertiban Umum",
|
||||
"Membahayakan Kam Umum",
|
||||
"Kenakalan Remaja",
|
||||
"Perlindungan Anak",
|
||||
"Perlindungan TKI",
|
||||
"Perlindungan Saksi – Korban",
|
||||
"Pekerjakan Anak",
|
||||
"Pengrusakan",
|
||||
"ITE",
|
||||
"Perlindungan Konsumen",
|
||||
];
|
||||
|
||||
if (high.includes(crimeName)) return "High";
|
||||
if (medium.includes(crimeName)) return "Medium";
|
||||
if (low.includes(crimeName)) return "Low";
|
||||
return "Unknown";
|
||||
}
|
|
@ -104,7 +104,7 @@ export interface IIncidentLogs {
|
|||
source: string;
|
||||
description: string;
|
||||
verified: boolean;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
severity: "Low" | "Medium" | "High" | "Unknown";
|
||||
timestamp: Date;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
|
|
|
@ -1,33 +1,157 @@
|
|||
export const districtCenters = [
|
||||
{ kecamatan: "Sumbersari", lat: -8.170662, lng: 113.727582 },
|
||||
{ kecamatan: "Wuluhan", lat: -8.365478, lng: 113.537137 },
|
||||
{ kecamatan: "Bangsalsari", lat: -8.2013, lng: 113.5323 },
|
||||
{ kecamatan: "Kaliwates", lat: -8.1725, lng: 113.7000 },
|
||||
{ kecamatan: "Puger", lat: -8.477942, lng: 113.370447 },
|
||||
{ kecamatan: "Ambulu", lat: -8.381355, lng: 113.608561 },
|
||||
{ kecamatan: "Silo", lat: -8.22917, lng: 113.86500 },
|
||||
{ kecamatan: "Sumberbaru", lat: -8.119229, lng: 113.392973 },
|
||||
{ kecamatan: "Patrang", lat: -8.14611, lng: 113.71250 },
|
||||
{ kecamatan: "Tanggul", lat: -8.161572, lng: 113.451239 },
|
||||
{ kecamatan: "Jenggawah", lat: -8.296967, lng: 113.656173 },
|
||||
{ kecamatan: "Gumukmas", lat: -8.32306, lng: 113.40667 },
|
||||
{ kecamatan: "Rambipuji", lat: -8.210581, lng: 113.603610 },
|
||||
{ kecamatan: "Ajung", lat: -8.245360, lng: 113.653218 },
|
||||
{ kecamatan: "Balung", lat: -8.26861, lng: 113.52667 },
|
||||
{ kecamatan: "Tempurejo", lat: -8.4000, lng: 113.8000 },
|
||||
{ kecamatan: "Kalisat", lat: -8.1500, lng: 113.8000 },
|
||||
{ kecamatan: "Umbulsari", lat: -8.2500, lng: 113.5000 },
|
||||
{ kecamatan: "Kencong", lat: -8.3500, lng: 113.4000 },
|
||||
{ kecamatan: "Ledokombo", lat: -8.2000, lng: 113.8000 },
|
||||
{ kecamatan: "Mumbulsari", lat: -8.3000, lng: 113.6000 },
|
||||
{ kecamatan: "Panti", lat: -8.1500, lng: 113.6000 },
|
||||
{ kecamatan: "Sumberjambe", lat: -8.1000, lng: 113.8000 },
|
||||
{ kecamatan: "Sukowono", lat: -8.2000, lng: 113.7000 },
|
||||
{ kecamatan: "Mayang", lat: -8.2500, lng: 113.7000 },
|
||||
{ kecamatan: "Semboro", lat: -8.3000, lng: 113.5000 },
|
||||
{ kecamatan: "Jombang", lat: -8.3500, lng: 113.5000 },
|
||||
{ kecamatan: "Pakusari", lat: -8.2000, lng: 113.7000 },
|
||||
{ kecamatan: "Arjasa", lat: -8.1500, lng: 113.7000 },
|
||||
{ kecamatan: "Sukorambi", lat: -8.2000, lng: 113.6000 },
|
||||
{ kecamatan: "Jelbuk", lat: -8.1000, lng: 113.7000 },
|
||||
{
|
||||
kecamatan: "Ajung",
|
||||
lat: -8.243799,
|
||||
lng: 113.642826,
|
||||
},
|
||||
{
|
||||
kecamatan: "Ambulu",
|
||||
lat: -8.3456,
|
||||
lng: 113.6106,
|
||||
},
|
||||
{
|
||||
kecamatan: "Arjasa",
|
||||
lat: -8.1042,
|
||||
lng: 113.7398,
|
||||
},
|
||||
{
|
||||
kecamatan: "Balung",
|
||||
lat: -8.2592,
|
||||
lng: 113.5464,
|
||||
},
|
||||
{
|
||||
kecamatan: "Bangsalsari",
|
||||
lat: -8.1721,
|
||||
lng: 113.5312,
|
||||
},
|
||||
{
|
||||
kecamatan: "Gumukmas",
|
||||
lat: -8.3117,
|
||||
lng: 113.4297,
|
||||
},
|
||||
{
|
||||
kecamatan: "Jelbuk",
|
||||
lat: -8.0831,
|
||||
lng: 113.7649,
|
||||
},
|
||||
{
|
||||
kecamatan: "Jenggawah",
|
||||
lat: -8.2409,
|
||||
lng: 113.6407,
|
||||
},
|
||||
{
|
||||
kecamatan: "Jombang",
|
||||
lat: -8.227065,
|
||||
lng: 113.345340,
|
||||
},
|
||||
{
|
||||
kecamatan: "Kalisat",
|
||||
lat: -8.1297,
|
||||
lng: 113.8218,
|
||||
},
|
||||
{
|
||||
kecamatan: "Kaliwates",
|
||||
lat: -8.1725,
|
||||
lng: 113.6875,
|
||||
},
|
||||
{
|
||||
kecamatan: "Kencong",
|
||||
lat: -8.2827,
|
||||
lng: 113.3769,
|
||||
},
|
||||
{
|
||||
kecamatan: "Ledokombo",
|
||||
lat: -8.1582,
|
||||
lng: 113.9055,
|
||||
},
|
||||
{
|
||||
kecamatan: "Mayang",
|
||||
lat: -8.1903,
|
||||
lng: 113.8264,
|
||||
},
|
||||
{
|
||||
kecamatan: "Mumbulsari",
|
||||
lat: -8.252744,
|
||||
lng: 113.741842,
|
||||
},
|
||||
{
|
||||
kecamatan: "Panti",
|
||||
lat: -8.1054,
|
||||
lng: 113.6218,
|
||||
},
|
||||
{
|
||||
kecamatan: "Pakusari",
|
||||
lat: -8.1308,
|
||||
lng: 113.7577,
|
||||
},
|
||||
{
|
||||
kecamatan: "Patrang",
|
||||
lat: -8.1402,
|
||||
lng: 113.7128,
|
||||
},
|
||||
{
|
||||
kecamatan: "Puger",
|
||||
lat: -8.314691,
|
||||
lng: 113.474320,
|
||||
},
|
||||
{
|
||||
kecamatan: "Rambipuji",
|
||||
lat: -8.2069,
|
||||
lng: 113.6075,
|
||||
},
|
||||
{
|
||||
kecamatan: "Semboro",
|
||||
lat: -8.1876,
|
||||
lng: 113.4583,
|
||||
},
|
||||
{
|
||||
kecamatan: "Silo",
|
||||
lat: -8.2276,
|
||||
lng: 113.8757,
|
||||
},
|
||||
{
|
||||
kecamatan: "Sukorambi",
|
||||
lat: -8.1094,
|
||||
lng: 113.6589,
|
||||
},
|
||||
{
|
||||
kecamatan: "Sukowono",
|
||||
lat: -8.0547,
|
||||
lng: 113.8853,
|
||||
},
|
||||
{
|
||||
kecamatan: "Sumberbaru",
|
||||
lat: -8.1362,
|
||||
lng: 113.3879,
|
||||
},
|
||||
{
|
||||
kecamatan: "Sumberjambe",
|
||||
lat: -8.0634,
|
||||
lng: 113.9351,
|
||||
},
|
||||
{
|
||||
kecamatan: "Sumbersari",
|
||||
lat: -8.1834,
|
||||
lng: 113.7226,
|
||||
},
|
||||
{
|
||||
kecamatan: "Tanggul",
|
||||
lat: -8.096066,
|
||||
lng: 113.492787,
|
||||
},
|
||||
{
|
||||
kecamatan: "Tempurejo",
|
||||
lat: -8.388294936847124,
|
||||
lng: 113.73276107141628,
|
||||
},
|
||||
{
|
||||
kecamatan: "Umbulsari",
|
||||
lat: -8.2623,
|
||||
lng: 113.4633,
|
||||
},
|
||||
{
|
||||
kecamatan: "Wuluhan",
|
||||
lat: -8.3283,
|
||||
lng: 113.5357,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -30,13 +30,13 @@ class DatabaseSeeder {
|
|||
|
||||
// Daftar semua seeders di sini
|
||||
this.seeders = [
|
||||
new RoleSeeder(prisma),
|
||||
new ResourceSeeder(prisma),
|
||||
new PermissionSeeder(prisma),
|
||||
new CrimeCategoriesSeeder(prisma),
|
||||
new GeoJSONSeeder(prisma),
|
||||
new UnitSeeder(prisma),
|
||||
new DemographicsSeeder(prisma),
|
||||
// new RoleSeeder(prisma),
|
||||
// new ResourceSeeder(prisma),
|
||||
// new PermissionSeeder(prisma),
|
||||
// new CrimeCategoriesSeeder(prisma),
|
||||
// new GeoJSONSeeder(prisma),
|
||||
// new UnitSeeder(prisma),
|
||||
// new DemographicsSeeder(prisma),
|
||||
new CrimesSeeder(prisma),
|
||||
// new CrimeIncidentsByUnitSeeder(prisma),
|
||||
new CrimeIncidentsByTypeSeeder(prisma),
|
||||
|
|
|
@ -696,9 +696,6 @@ export class CrimeIncidentsByTypeSeeder {
|
|||
console.log(`\n📊 ${year} Data Summary:`);
|
||||
console.log(`├─ Total incidents created: ${stats.incidents}`);
|
||||
console.log(`├─ Districts processed: ${stats.districts.size}`);
|
||||
console.log(
|
||||
`├─ Categories: ${stats.matched} matched, ${stats.mismatched} mismatched`,
|
||||
);
|
||||
console.log(`└─ Total resolved cases: ${stats.resolved}`);
|
||||
}
|
||||
|
||||
|
@ -731,22 +728,18 @@ export class CrimeIncidentsByTypeSeeder {
|
|||
const centerLat = districtCenter.lat;
|
||||
const centerLng = districtCenter.lng;
|
||||
|
||||
let scalingFactor = 0.3;
|
||||
|
||||
const effectiveLandArea = Math.max(landArea || 1, 1);
|
||||
|
||||
const radiusKm = Math.sqrt(effectiveLandArea) * scalingFactor;
|
||||
// Skala kecil: radius hanya 0.5 km dari center (sekitar 500 meter)
|
||||
const radiusKm = 0.5;
|
||||
const radiusDeg = radiusKm / 111;
|
||||
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
const distanceFactor = Math.pow(Math.random(), 1.5);
|
||||
const distance = distanceFactor * radiusDeg;
|
||||
// Jarak random, lebih padat di tengah
|
||||
const distance = Math.pow(Math.random(), 1.5) * radiusDeg;
|
||||
|
||||
const latitude = centerLat + distance * Math.cos(angle);
|
||||
const longitude = centerLng +
|
||||
distance * Math.sin(angle) /
|
||||
Math.cos(centerLat * Math.PI / 180);
|
||||
distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
|
||||
|
||||
const pointRadius = distance * 111000;
|
||||
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import {
|
||||
PrismaClient,
|
||||
crime_incidents,
|
||||
crime_rates,
|
||||
crimes,
|
||||
events,
|
||||
PrismaClient,
|
||||
session_status,
|
||||
users,
|
||||
} from '@prisma/client';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { generateId, generateIdWithDbCounter } from '../../app/_utils/common';
|
||||
import { CRegex } from '../../app/_utils/const/regex';
|
||||
|
||||
} from "@prisma/client";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { generateId, generateIdWithDbCounter } from "../../app/_utils/common";
|
||||
import { CRegex } from "../../app/_utils/const/regex";
|
||||
import db from "../db";
|
||||
|
||||
interface ICreateUser {
|
||||
id: string;
|
||||
|
@ -34,14 +34,17 @@ export class CrimesSeeder {
|
|||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
console.log('🌱 Seeding crimes data...');
|
||||
console.log("🌱 Seeding crimes data...");
|
||||
|
||||
try {
|
||||
// Create test user
|
||||
const user = await this.createUsers();
|
||||
|
||||
await db.crime_incidents.deleteMany();
|
||||
await db.crimes.deleteMany();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Failed to create user');
|
||||
throw new Error("Failed to create user");
|
||||
}
|
||||
|
||||
// Create 5 events
|
||||
|
@ -60,39 +63,39 @@ export class CrimesSeeder {
|
|||
await this.importYearlyCrimeDataByType();
|
||||
await this.importSummaryByType();
|
||||
|
||||
console.log('✅ Crime seeding completed successfully.');
|
||||
console.log("✅ Crime seeding completed successfully.");
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding crimes:', error);
|
||||
console.error("❌ Error seeding crimes:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createUsers() {
|
||||
const existingUser = await this.prisma.users.findFirst({
|
||||
where: { email: 'sigapcompany@gmail.com' },
|
||||
where: { email: "sigapcompany@gmail.com" },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log('Users already exist, skipping creation.');
|
||||
console.log("Users already exist, skipping creation.");
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
let roleId = await this.prisma.roles.findFirst({
|
||||
where: { name: 'admin' },
|
||||
where: { name: "admin" },
|
||||
});
|
||||
|
||||
if (!roleId) {
|
||||
roleId = await this.prisma.roles.create({
|
||||
data: {
|
||||
name: 'admin',
|
||||
description: 'Administrator role',
|
||||
name: "admin",
|
||||
description: "Administrator role",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newUser = await this.prisma.users.create({
|
||||
data: {
|
||||
email: 'sigapcompany@gmail.com',
|
||||
email: "sigapcompany@gmail.com",
|
||||
roles_id: roleId.id,
|
||||
confirmed_at: new Date(),
|
||||
email_confirmed_at: new Date(),
|
||||
|
@ -106,9 +109,9 @@ export class CrimesSeeder {
|
|||
is_anonymous: false,
|
||||
profile: {
|
||||
create: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'Sigap',
|
||||
username: 'adminsigap',
|
||||
first_name: "Admin",
|
||||
last_name: "Sigap",
|
||||
username: "adminsigap",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -125,7 +128,7 @@ export class CrimesSeeder {
|
|||
});
|
||||
|
||||
if (existingEvent) {
|
||||
console.log('Events already exist, skipping creation.');
|
||||
console.log("Events already exist, skipping creation.");
|
||||
return existingEvent;
|
||||
}
|
||||
|
||||
|
@ -144,7 +147,7 @@ export class CrimesSeeder {
|
|||
const existingSession = await this.prisma.sessions.findFirst();
|
||||
|
||||
if (existingSession) {
|
||||
console.log('Sessions already exist, skipping creation.');
|
||||
console.log("Sessions already exist, skipping creation.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -169,24 +172,24 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
private async importMonthlyCrimeData() {
|
||||
console.log('Importing monthly crime data...');
|
||||
console.log("Importing monthly crime data...");
|
||||
|
||||
const existingCrimes = await this.prisma.crimes.findFirst({
|
||||
where: {
|
||||
source_type: 'cbu',
|
||||
source_type: "cbu",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCrimes) {
|
||||
console.log('General crimes data already exists, skipping import.');
|
||||
console.log("General crimes data already exists, skipping import.");
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFilePath = path.resolve(
|
||||
__dirname,
|
||||
'../data/excels/crimes/crime_monthly_by_unit.csv'
|
||||
"../data/excels/crimes/crime_monthly_by_unit.csv",
|
||||
);
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||
|
||||
const records = parse(fileContent, {
|
||||
columns: true,
|
||||
|
@ -201,30 +204,30 @@ export class CrimesSeeder {
|
|||
|
||||
const city = await this.prisma.cities.findFirst({
|
||||
where: {
|
||||
name: 'Jember',
|
||||
name: "Jember",
|
||||
},
|
||||
});
|
||||
|
||||
if (!city) {
|
||||
console.error('City not found: Jember');
|
||||
console.error("City not found: Jember");
|
||||
return;
|
||||
}
|
||||
|
||||
const year = parseInt(record.year);
|
||||
const crimeId = await generateIdWithDbCounter(
|
||||
'crimes',
|
||||
"crimes",
|
||||
{
|
||||
prefix: 'CR',
|
||||
prefix: "CR",
|
||||
segments: {
|
||||
codes: [city.id],
|
||||
sequentialDigits: 4,
|
||||
year,
|
||||
},
|
||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
||||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
|
@ -237,7 +240,7 @@ export class CrimesSeeder {
|
|||
number_of_crime: parseInt(record.number_of_crime),
|
||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||
score: parseFloat(record.score),
|
||||
source_type: 'cbu',
|
||||
source_type: "cbu",
|
||||
});
|
||||
|
||||
processedDistricts.add(record.district_id);
|
||||
|
@ -249,25 +252,25 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
private async importYearlyCrimeData() {
|
||||
console.log('Importing yearly crime data...');
|
||||
console.log("Importing yearly crime data...");
|
||||
|
||||
const existingYearlySummary = await this.prisma.crimes.findFirst({
|
||||
where: {
|
||||
month: null,
|
||||
source_type: 'cbu',
|
||||
source_type: "cbu",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingYearlySummary) {
|
||||
console.log('Yearly crime data already exists, skipping import.');
|
||||
console.log("Yearly crime data already exists, skipping import.");
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFilePath = path.resolve(
|
||||
__dirname,
|
||||
'../data/excels/crimes/crime_yearly_by_unit.csv'
|
||||
"../data/excels/crimes/crime_yearly_by_unit.csv",
|
||||
);
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||
|
||||
const records = parse(fileContent, {
|
||||
columns: true,
|
||||
|
@ -296,33 +299,33 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
const crimeId = await generateIdWithDbCounter(
|
||||
'crimes',
|
||||
"crimes",
|
||||
{
|
||||
prefix: 'CR',
|
||||
prefix: "CR",
|
||||
segments: {
|
||||
codes: [city.id],
|
||||
sequentialDigits: 4,
|
||||
year,
|
||||
},
|
||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
||||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
id: crimeId,
|
||||
district_id: record.district_id,
|
||||
level: crimeRate,
|
||||
method: record.method || 'kmeans',
|
||||
method: record.method || "kmeans",
|
||||
month: null,
|
||||
year: year,
|
||||
number_of_crime: parseInt(record.number_of_crime),
|
||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||
score: parseInt(record.score),
|
||||
source_type: 'cbu',
|
||||
source_type: "cbu",
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -332,26 +335,26 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
private async importAllYearSummaries() {
|
||||
console.log('Importing all-year (2020-2024) crime summaries...');
|
||||
console.log("Importing all-year (2020-2024) crime summaries...");
|
||||
|
||||
const existingAllYearSummaries = await this.prisma.crimes.findFirst({
|
||||
where: {
|
||||
month: null,
|
||||
year: null,
|
||||
source_type: 'cbu',
|
||||
source_type: "cbu",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAllYearSummaries) {
|
||||
console.log('All-year crime summaries already exist, skipping import.');
|
||||
console.log("All-year crime summaries already exist, skipping import.");
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFilePath = path.resolve(
|
||||
__dirname,
|
||||
'../data/excels/crimes/crime_summary_by_unit.csv'
|
||||
"../data/excels/crimes/crime_summary_by_unit.csv",
|
||||
);
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||
|
||||
const records = parse(fileContent, {
|
||||
columns: true,
|
||||
|
@ -380,32 +383,32 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
const crimeId = await generateIdWithDbCounter(
|
||||
'crimes',
|
||||
"crimes",
|
||||
{
|
||||
prefix: 'CR',
|
||||
prefix: "CR",
|
||||
segments: {
|
||||
codes: [city.id],
|
||||
sequentialDigits: 4,
|
||||
},
|
||||
format: '{prefix}-{codes}-{sequence}',
|
||||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
format: "{prefix}-{codes}-{sequence}",
|
||||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_SEQUENCE_END
|
||||
CRegex.CR_SEQUENCE_END,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
id: crimeId,
|
||||
district_id: districtId,
|
||||
level: crimeRate,
|
||||
method: 'kmeans',
|
||||
method: "kmeans",
|
||||
month: null,
|
||||
year: null,
|
||||
number_of_crime: parseInt(record.crime_total),
|
||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||
score: parseFloat(record.score),
|
||||
source_type: 'cbu',
|
||||
source_type: "cbu",
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -415,24 +418,24 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
private async importMonthlyCrimeDataByType() {
|
||||
console.log('Importing monthly crime data by type...');
|
||||
console.log("Importing monthly crime data by type...");
|
||||
|
||||
const existingCrimeByType = await this.prisma.crimes.findFirst({
|
||||
where: {
|
||||
source_type: 'cbt',
|
||||
source_type: "cbt",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCrimeByType) {
|
||||
console.log('Crime data by type already exists, skipping import.');
|
||||
console.log("Crime data by type already exists, skipping import.");
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFilePath = path.resolve(
|
||||
__dirname,
|
||||
'../data/excels/crimes/crime_monthly_by_type.csv'
|
||||
"../data/excels/crimes/crime_monthly_by_type.csv",
|
||||
);
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||
|
||||
const records = parse(fileContent, {
|
||||
columns: true,
|
||||
|
@ -446,43 +449,43 @@ export class CrimesSeeder {
|
|||
|
||||
const city = await this.prisma.cities.findFirst({
|
||||
where: {
|
||||
name: 'Jember',
|
||||
name: "Jember",
|
||||
},
|
||||
});
|
||||
|
||||
if (!city) {
|
||||
console.error('City not found: Jember');
|
||||
console.error("City not found: Jember");
|
||||
continue;
|
||||
}
|
||||
|
||||
const year = parseInt(record.year);
|
||||
const crimeId = await generateIdWithDbCounter(
|
||||
'crimes',
|
||||
"crimes",
|
||||
{
|
||||
prefix: 'CR',
|
||||
prefix: "CR",
|
||||
segments: {
|
||||
codes: [city.id],
|
||||
sequentialDigits: 4,
|
||||
year,
|
||||
},
|
||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
||||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
id: crimeId,
|
||||
district_id: record.district_id,
|
||||
level: crimeRate,
|
||||
method: record.method || 'kmeans',
|
||||
method: record.method || "kmeans",
|
||||
month: parseInt(record.month_num),
|
||||
year: parseInt(record.year),
|
||||
number_of_crime: parseInt(record.number_of_crime),
|
||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||
score: parseFloat(record.score),
|
||||
source_type: 'cbt',
|
||||
source_type: "cbt",
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -492,25 +495,25 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
private async importYearlyCrimeDataByType() {
|
||||
console.log('Importing yearly crime data by type...');
|
||||
console.log("Importing yearly crime data by type...");
|
||||
|
||||
const existingYearlySummary = await this.prisma.crimes.findFirst({
|
||||
where: {
|
||||
month: null,
|
||||
source_type: 'cbt',
|
||||
source_type: "cbt",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingYearlySummary) {
|
||||
console.log('Yearly crime data by type already exists, skipping import.');
|
||||
console.log("Yearly crime data by type already exists, skipping import.");
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFilePath = path.resolve(
|
||||
__dirname,
|
||||
'../data/excels/crimes/crime_yearly_by_type.csv'
|
||||
"../data/excels/crimes/crime_yearly_by_type.csv",
|
||||
);
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||
|
||||
const records = parse(fileContent, {
|
||||
columns: true,
|
||||
|
@ -539,33 +542,33 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
const crimeId = await generateIdWithDbCounter(
|
||||
'crimes',
|
||||
"crimes",
|
||||
{
|
||||
prefix: 'CR',
|
||||
prefix: "CR",
|
||||
segments: {
|
||||
codes: [city.id],
|
||||
sequentialDigits: 4,
|
||||
year,
|
||||
},
|
||||
format: '{prefix}-{codes}-{sequence}-{year}',
|
||||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
format: "{prefix}-{codes}-{sequence}-{year}",
|
||||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_YEAR_SEQUENCE
|
||||
CRegex.CR_YEAR_SEQUENCE,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
id: crimeId,
|
||||
district_id: record.district_id,
|
||||
level: crimeRate,
|
||||
method: record.method || 'kmeans',
|
||||
method: record.method || "kmeans",
|
||||
month: null,
|
||||
year: year,
|
||||
number_of_crime: parseInt(record.number_of_crime),
|
||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||
score: parseInt(record.score),
|
||||
source_type: 'cbt',
|
||||
source_type: "cbt",
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -575,24 +578,24 @@ export class CrimesSeeder {
|
|||
}
|
||||
|
||||
private async importSummaryByType() {
|
||||
console.log('Importing crime summary by type...');
|
||||
console.log("Importing crime summary by type...");
|
||||
|
||||
const existingSummary = await this.prisma.crimes.findFirst({
|
||||
where: {
|
||||
source_type: 'cbt',
|
||||
source_type: "cbt",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSummary) {
|
||||
console.log('Crime summary by type already exists, skipping import.');
|
||||
console.log("Crime summary by type already exists, skipping import.");
|
||||
return;
|
||||
}
|
||||
|
||||
const csvFilePath = path.resolve(
|
||||
__dirname,
|
||||
'../data/excels/crimes/crime_summary_by_type.csv'
|
||||
"../data/excels/crimes/crime_summary_by_type.csv",
|
||||
);
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
|
||||
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
|
||||
|
||||
const records = parse(fileContent, {
|
||||
columns: true,
|
||||
|
@ -606,42 +609,42 @@ export class CrimesSeeder {
|
|||
|
||||
const city = await this.prisma.cities.findFirst({
|
||||
where: {
|
||||
name: 'Jember',
|
||||
name: "Jember",
|
||||
},
|
||||
});
|
||||
|
||||
if (!city) {
|
||||
console.error('City not found: Jember');
|
||||
console.error("City not found: Jember");
|
||||
continue;
|
||||
}
|
||||
|
||||
const crimeId = await generateIdWithDbCounter(
|
||||
'crimes',
|
||||
"crimes",
|
||||
{
|
||||
prefix: 'CR',
|
||||
prefix: "CR",
|
||||
segments: {
|
||||
codes: [city.id],
|
||||
sequentialDigits: 4,
|
||||
},
|
||||
format: '{prefix}-{codes}-{sequence}',
|
||||
separator: '-',
|
||||
uniquenessStrategy: 'counter',
|
||||
format: "{prefix}-{codes}-{sequence}",
|
||||
separator: "-",
|
||||
uniquenessStrategy: "counter",
|
||||
},
|
||||
CRegex.CR_SEQUENCE_END
|
||||
CRegex.CR_SEQUENCE_END,
|
||||
);
|
||||
|
||||
crimesData.push({
|
||||
id: crimeId,
|
||||
district_id: record.district_id,
|
||||
level: crimeRate,
|
||||
method: 'kmeans',
|
||||
method: "kmeans",
|
||||
month: null,
|
||||
year: null,
|
||||
number_of_crime: parseInt(record.crime_total),
|
||||
crime_cleared: parseInt(record.crime_cleared) || 0,
|
||||
avg_crime: parseFloat(record.avg_crime) || 0,
|
||||
score: parseFloat(record.score),
|
||||
source_type: 'cbt',
|
||||
source_type: "cbt",
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -659,7 +662,7 @@ if (require.main === module) {
|
|||
try {
|
||||
await seeder.run();
|
||||
} catch (e) {
|
||||
console.error('Error during seeding:', e);
|
||||
console.error("Error during seeding:", e);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
|
|
Loading…
Reference in New Issue