From 2a8f249d0ca2c4044e0a8fdb8d6ecc48f29d073f Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Mon, 12 May 2025 12:24:50 +0700 Subject: [PATCH] add filter source_type --- .../crime-overview/_queries/queries.ts | 16 + .../crime-management/crime-overview/action.ts | 258 ++++-- .../map/controls/left/sidebar/map-sidebar.tsx | 53 +- .../left/sidebar/tabs/incidents-tab.tsx | 67 +- .../controls/left/sidebar/tabs/info-tab.tsx | 283 +++--- .../left/sidebar/tabs/statistics-tab.tsx | 415 +++++++-- .../map/controls/source-type-selector.tsx | 69 ++ .../map/controls/top/additional-tooltips.tsx | 232 ++--- .../map/controls/top/crime-tooltips.tsx | 91 +- .../map/controls/top/search-control.tsx | 822 +++++++++--------- .../_components/map/controls/top/tooltips.tsx | 76 +- .../app/_components/map/crime-map.tsx | 66 +- .../_components/map/layers/cluster-layer.tsx | 434 ++++++++- .../app/_components/map/layers/layers.tsx | 7 +- sigap-website/app/_utils/common.ts | 1 + sigap-website/app/_utils/crime.ts | 56 ++ sigap-website/app/_utils/types/crimes.ts | 2 +- .../prisma/data/jsons/district-center.ts | 186 +++- sigap-website/prisma/seed.ts | 14 +- .../prisma/seeds/crime-incidents-cbt.ts | 17 +- sigap-website/prisma/seeds/crimes.ts | 217 ++--- 21 files changed, 2332 insertions(+), 1050 deletions(-) create mode 100644 sigap-website/app/_components/map/controls/source-type-selector.tsx create mode 100644 sigap-website/app/_utils/crime.ts diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts index d53d385..9cb42f8 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts @@ -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(), + }); +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts index 053cf0f..a7b9433 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts @@ -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 { - 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 { 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 { + 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 { - 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 { - 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 { + 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.", + ); + } + }, ); } diff --git a/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx b/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx index 159b7e8..25ebf9d 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx @@ -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({
- {/* Header with improved styling */}
- {/* Improved tabs with pill style */} + Dashboard + ) : ( <> + @@ -234,11 +225,13 @@ export default function CrimeSidebar({ crimeStats={crimeStats} selectedMonth={selectedMonth} selectedYear={selectedYear} + sourceType={sourceType} + crimes={crimes} /> - + )} diff --git a/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx b/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx index c7fca91..770fa1c 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx @@ -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 ( + + +
+ +
+ +

Limited Data View

+ +

+ The CBU data source only provides aggregated statistics without detailed incident information. +

+ +
+
+ Current Data Source: + CBU +
+ +
+ Recommended: + CBT +
+
+ +

+ To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source. +

+ +
+ +
+ +
+

+ The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information. +

+
+
+
+ ); + } + return ( <> {/* Enhanced info card */} @@ -93,7 +146,7 @@ export function SidebarIncidentsTab({ {location}
- + {crimeStats.totalIncidents || 0} incidents reported {selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`} @@ -116,8 +169,8 @@ export function SidebarIncidentsTab({ } - statusColor="text-amber-400" + statusIcon={} + 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({
- +

{formatMonthKey(monthKey)}

diff --git a/sigap-website/app/_components/map/controls/left/sidebar/tabs/info-tab.tsx b/sigap-website/app/_components/map/controls/left/sidebar/tabs/info-tab.tsx index ee84835..eab110d 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/tabs/info-tab.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/tabs/info-tab.tsx @@ -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 ( <> }> @@ -29,123 +33,175 @@ export function SidebarInfoTab() { + {/* Show different map markers based on source type */}

Map Markers

-
- - Individual Incident -
+ {sourceType === "cbt" ? ( + // Detailed incidents for CBT + <> +
+ + Individual Incident +
5
Incident Cluster
+ + ) : ( + // Simplified view for CBU + <> +
+
12
+ District Crime Count +
+
+ Shows aggregated crime counts by district. Size indicates relative crime volume. +
+ + )}
+ {/* Show layers info based on source type */} }> -
-

- - Incidents Layer -

-

- 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. -

-
+ {sourceType === "cbt" ? ( + // Show all layers for CBT + <> +
+

+ + Incidents Layer +

+

+ 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. +

+
-
-

- - Clusters Layer -

-

- 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. -

-
-
-
- 1-5 +
+

+ + Clusters Layer +

+

+ 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. +

+
+
+
+ 1-5 +
+
+
+ 6-15 +
+
+
+ 15+ +
+
-
-
- 6-15 + +
+

+ + Heatmap Layer +

+

+ 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. +

+
+
+
+ Low + Density + High +
+
-
-
- 15+ + +
+

+ + Units Layer +

+

+ 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. +

+
+
+ Police/Security Unit +
+
+ +
+

+ + Timeline Layer +

+

+ 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. +

+
+
+
+ Morning +
+
+
+ Afternoon +
+
+
+ Evening +
+
+
+ Night +
+
+
+ + ) : ( + // Show limited layers info for CBU +
+

+ + District Crime Data +

+

+ 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). +

+

+ The size of each point indicates the relative number of crimes reported in that district. +

+
+
+
+ Low +
+
+
+ Medium +
+
+
+ High +
-
- -
-

- - Heatmap Layer -

-

- 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. -

-
-
-
- Low - Density - High -
-
-
- -
-

- - Units Layer -

-

- 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. -

-
-
- Police/Security Unit -
-
- -
-

- - Timeline Layer -

-

- 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. -

-
-
-
- Morning -
-
-
- Afternoon -
-
-
- Evening -
-
-
- Night -
-
-
+ )} @@ -170,6 +226,12 @@ export function SidebarInfoTab() { Last Updated June 18, 2024
+ {sourceType && ( +
+ Data Source + {sourceType.toUpperCase()} +
+ )}
@@ -203,17 +265,20 @@ export function SidebarInfoTab() {
-
-
- + {/* Show incident details help only for CBT */} + {sourceType === "cbt" && ( +
+
+ +
+
+ Incidents +

+ Click on incident markers to view details about specific crime reports. +

+
-
- Incidents -

- Click on incident markers to view details about specific crime reports. -

-
-
+ )} diff --git a/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx b/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx index 1b3eef0..58848e4 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx @@ -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[] }) => ( +
+ {monthlyData.map((count: number, i: number) => { + const maxCount = Math.max(...monthlyData); + const height = maxCount > 0 ? (count / maxCount) * 100 : 0; + + return ( +
+ ); + })} +
+); + +// Component for rendering yearly trend chart +const YearlyTrendChart = ({ crimes }: { crimes: ICrimes[] }) => { + const yearCounts = React.useMemo(() => { + const counts = new Map(); + 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 ( +
+ No yearly data available +
+ ); + } + + return ( +
+
+
+ {years.map((year, i) => { + const count = values[i]; + const maxCount = Math.max(...values); + const height = maxCount > 0 ? (count / maxCount) * 100 : 0; + + return ( +
+
+ {year} +
+ ); + })} +
+
+
+ ); +}; + +// Component for district distribution chart +const DistrictDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => { + const districtData = React.useMemo(() => { + const districtCounts = new Map(); + 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 ( + + + + + Top Districts + + Crime distribution by area + + + No district data available + + + ); + } + + return ( + + + + + Top Districts + + Crime distribution by area + + +
+ {districtData.map((district, index) => ( +
+
+ {district.name} + {district.count} incidents +
+
+
+
+
+ ))} +
+ + + ); +}; + +// 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 ( + + + + + Time of Day + + When incidents occur + + +
+ {timeData.map((time, index) => ( +
+ ))} +
+
+ {timeData.map((time, index) => ( +
+
+ {time.percentage}% +
+ ))} +
+ + + ); +}; + 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 ( + + +
+ +
+ +

Limited Data View

+ +

+ The CBU data source only provides aggregated statistics without detailed incident information. +

+ +
+
+ Current Data Source: + CBU +
+ +
+ Recommended: + CBT +
+
+ +

+ To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source. +

+ +
+ + + +
+ +
+

+ The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information. +

+
+
+
+ ); + } + return ( <> + @@ -42,27 +354,7 @@ export function SidebarStatisticsTab({ {selectedYear} -
- {crimeStats.incidentsByMonth.map((count: number, i: number) => { - const maxCount = Math.max(...crimeStats.incidentsByMonth) - const height = maxCount > 0 ? (count / maxCount) * 100 : 0 - - return ( -
- ) - })} -
+
{MONTHS.map((month, i) => ( @@ -109,32 +401,57 @@ export function SidebarStatisticsTab({
+ + + + + Yearly Trends + + Historical Overview + + + + + + - }> -
- {topCategories.length > 0 ? ( - topCategories.map((category: ICrimeTypeCardProps) => ( - - )) - ) : ( - - -
- -

No crime data available

-

Try selecting a different time period

-
-
-
- )} -
-
+ + +
+ +
+ + {sourceType === "cbt" && ( + <> + + + }> +
+ {topCategories.length > 0 ? ( + topCategories.map((category: ICrimeTypeCardProps) => ( + + )) + ) : ( + + +
+ +

No crime data available

+

Try selecting a different time period

+
+
+
+ )} +
+
+ + )} ) } diff --git a/sigap-website/app/_components/map/controls/source-type-selector.tsx b/sigap-website/app/_components/map/controls/source-type-selector.tsx new file mode 100644 index 0000000..96aac60 --- /dev/null +++ b/sigap-website/app/_components/map/controls/source-type-selector.tsx @@ -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(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 ( +
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
+ ) +} diff --git a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx index fff586b..d48b75c 100644 --- a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx @@ -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: , label: "Police Report" }, - { id: "layers" as ITooltips, icon: , label: "Map Layers" }, { id: "alerts" as ITooltips, icon: , 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 ( <>
- {additionalTooltips.map((control) => ( - - - - - -

{control.label}

-
-
- ))} + } ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`} + onClick={() => onControlChange?.(control.id)} + disabled={isButtonDisabled} + aria-disabled={isButtonDisabled} + > + {control.icon} + {control.label} + + + +

{isButtonDisabled ? "Not available for CBU data" : control.label}

+
+ + ) + })} - - - - - - -
-
- Year: - -
-
- Month: - -
-
- Category: - -
-
-
-
-
-
-
+ + + + + + +
+
+ Source: + +
+
+ Year: + +
+
+ Month: + +
+
+ Category: + +
+
+
+
+
+ +
- {showSelectors && ( -
- - - -
- )} - - ) + {showSelectors && ( +
+ + + + +
+ )} + + ) } diff --git a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx index 27955ce..2777d98 100644 --- a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx @@ -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: , label: "All Incidents" }, { id: "heatmap" as ITooltips, icon: , label: "Density Heatmap" }, { id: "clusters" as ITooltips, icon: , label: "Clustered Incidents" }, - { id: "units" as ITooltips, icon: , label: "Police Units" }, + { id: "units" as ITooltips, icon: , label: "Police Units" }, { id: "patrol" as ITooltips, icon: , label: "Patrol Areas" }, { id: "timeline" as ITooltips, icon: , 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 (
- {crimeTooltips.map((control) => ( - - - - - -

{control.label}

-
-
- ))} -
-
- ) + {crimeTooltips.map((control) => { + const isButtonDisabled = isDisabled(control.id) + + return ( + + + + + +

{isButtonDisabled ? "Not available for CBU data" : control.label}

+
+
+ ) + })} + +
+ ) } diff --git a/sigap-website/app/_components/map/controls/top/search-control.tsx b/sigap-website/app/_components/map/controls/top/search-control.tsx index 59e9072..d4354b2 100644 --- a/sigap-website/app/_components/map/controls/top/search-control.tsx +++ b/sigap-website/app/_components/map/controls/top/search-control.tsx @@ -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(null) const [selectedSearchType, setSelectedSearchType] = useState(null) @@ -100,237 +116,244 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes = const [selectedSuggestion, setSelectedSuggestion] = useState(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 = - - -

Search Incidents

-
- - -
+ 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 Incidents + + + +

{isSearchDisabled ? "Not available for CBU data" : "Search Incidents"}

+
+ + +
- - {showSearch && ( - <> - + + {showSearch && ( + <> + - -
-
-

Search Incidents

- -
+ +
+
+

Search Incidents

+ +
- {!showInfoBox ? ( - <> - a.id === selectedSearchType)?.placeholder : - "Select a search type..."} - inputClassName={!isInputValid ? - "border-destructive focus-visible:ring-destructive bg-destructive/50" : ""} - /> + {!showInfoBox ? ( + <> + a.id === selectedSearchType)?.placeholder + : "Select a search type..." + } + inputClassName={ + !isInputValid ? "border-destructive focus-visible:ring-destructive bg-destructive/50" : "" + } + /> - {!isInputValid && selectedSearchType && ( -
- Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description} -
- )} + {!isInputValid && selectedSearchType && ( +
+ Invalid format. {ACTIONS.find((a) => a.id === selectedSearchType)?.description} +
+ )} - {(suggestions.length > 0 && selectedSearchType) && ( -
-
-

- {suggestions.length} results found - {suggestions.length === 50 && " (showing top 50)"} -

-
-
    - {suggestions.map((incident, index) => ( -
  • handleSuggestionSelect(incident)} - > - {incident.id} -
    - {selectedSearchType === 'incident_id' ? ( - - {incident.description} - - ) : selectedSearchType === 'coordinates' ? ( - - {incident.locations.latitude}, {incident.locations.longitude} - {incident.description} - - ) : selectedSearchType === 'locations.address' ? ( - - {incident.locations.address || 'N/A'} - - ) : ( - - {incident.description} - - )} - -
    -
  • - ))} -
-
- )} + {suggestions.length > 0 && selectedSearchType && ( +
+
+

+ {suggestions.length} results found + {suggestions.length === 50 && " (showing top 50)"} +

+
+
    + {suggestions.map((incident, index) => ( +
  • handleSuggestionSelect(incident)} + > + {incident.id} +
    + {selectedSearchType === "incident_id" ? ( + + {incident.description} + + ) : selectedSearchType === "coordinates" ? ( + + {incident.locations.latitude}, {incident.locations.longitude} -{" "} + {incident.description} + + ) : selectedSearchType === "locations.address" ? ( + + {incident.locations.address || "N/A"} + + ) : ( + + {incident.description} + + )} + +
    +
  • + ))} +
+
+ )} - {selectedSearchType && searchValue.length > (ACTIONS.find(a => a.id === selectedSearchType)?.prefix?.length || 0) && - suggestions.length === 0 && ( -
-

No matching incidents found

-
- )} + {selectedSearchType && + searchValue.length > (ACTIONS.find((a) => a.id === selectedSearchType)?.prefix?.length || 0) && + suggestions.length === 0 && ( +
+

No matching incidents found

+
+ )} -
-

- {selectedSearchType ? ( - <> - {ACTIONS.find(a => a.id === selectedSearchType)?.icon} - - {ACTIONS.find(a => a.id === selectedSearchType)?.description} - - - ) : ( - - Select a search type and enter your search criteria - - )} -

-
- - ) : ( - -
-

{selectedSuggestion?.id}

-
+
+

+ {selectedSearchType ? ( + <> + {ACTIONS.find((a) => a.id === selectedSearchType)?.icon} + {ACTIONS.find((a) => a.id === selectedSearchType)?.description} + + ) : ( + Select a search type and enter your search criteria + )} +

+
+ + ) : ( + +
+

{selectedSuggestion?.id}

+
- {selectedSuggestion && ( -
-
- -

{selectedSuggestion.description}

-
+ {selectedSuggestion && ( +
+
+ +

{selectedSuggestion.description}

+
- {selectedSuggestion.timestamp && ( -
- -

- {formatIncidentDate(selectedSuggestion)} -

-
- )} + {selectedSuggestion.timestamp && ( +
+ +

{formatIncidentDate(selectedSuggestion)}

+
+ )} - {selectedSuggestion.locations.address && ( -
- -

{selectedSuggestion.locations.address}

-
- )} + {selectedSuggestion.locations.address && ( +
+ +

{selectedSuggestion.locations.address}

+
+ )} -
-
-

Category

-

{selectedSuggestion.crime_categories?.name || 'N/A'}

-
-
-

Status

-

{selectedSuggestion.status || 'N/A'}

-
-
+
+
+

Category

+

{selectedSuggestion.crime_categories?.name || "N/A"}

+
+
+

Status

+

{selectedSuggestion.status || "N/A"}

+
+
-
- - -
-
- )} - - )} -
- - - )} - - - ) +
+ + +
+
+ )} + + )} +
+
+ + )} +
+ + ) } diff --git a/sigap-website/app/_components/map/controls/top/tooltips.tsx b/sigap-website/app/_components/map/controls/top/tooltips.tsx index 80d396b..6f0ac42 100644 --- a/sigap-website/app/_components/map/controls/top/tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/tooltips.tsx @@ -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(null) const [isClient, setIsClient] = useState(false) @@ -68,29 +74,37 @@ export default function Tooltips({
{/* Crime Tooltips Component */} - + - {/* Additional Tooltips Component */} - + {/* Additional Tooltips Component */} + - {/* Search Control Component */} - -
-
- ) + {/* Search Control Component */} + +
+
+ ) } diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 59e4831..439c34f 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -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(null) const [showLegend, setShowLegend] = useState(true) - const [selectedCategory, setSelectedCategory] = useState("all") + const [activeControl, setActiveControl] = useState("incidents") + const [selectedSourceType, setSelectedSourceType] = useState("cbu") const [selectedYear, setSelectedYear] = useState(2024) const [selectedMonth, setSelectedMonth] = useState("all") - const [activeControl, setActiveControl] = useState("incidents") + const [selectedCategory, setSelectedCategory] = useState("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() {
) : ( -
+
{isFullscreen && ( @@ -246,6 +288,9 @@ export default function CrimeMap() { {isFullscreen && (
diff --git a/sigap-website/app/_components/map/layers/cluster-layer.tsx b/sigap-website/app/_components/map/layers/cluster-layer.tsx index d053b0a..c07adaa 100644 --- a/sigap-website/app/_components/map/layers/cluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/cluster-layer.tsx @@ -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 = ` +
+

${props.district_name}

+
+

Total Crimes: ${props.crime_count}

+

Crime Level: ${props.level}

+

Year: ${props.year} - Month: ${props.month}

+ ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} +
+
+ ` + + 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 = ` +
+

${props.district_name}

+
+

Total Crimes: ${props.crime_count}

+

Crime Level: ${props.level}

+

Year: ${props.year} - Month: ${props.month}

+ ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} +
+
+ ` + + 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 = ` +
+

${props.district_name}

+
+

Total Crimes: ${props.crime_count}

+

Crime Level: ${props.level}

+

Year: ${props.year} - Month: ${props.month}

+ ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} +
+
+ `; + + 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 } diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index a6bbf6e..0a96911 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -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} /> { - 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();