From a19e8ec32d5e1bc21672ec953705b99ab354d1aa Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Mon, 5 May 2025 10:38:11 +0700 Subject: [PATCH] feat: Add timeline and units layers to the map component - Introduced a new TimelineLayer to visualize average incident times per district. - Added UnitsLayer to display police stations and connection lines to incidents. - Updated Layers component to conditionally render the new layers based on active controls. - Implemented a query to fetch units data from the server. - Created utility functions for color generation based on crime categories. - Enhanced map interaction with popups for detailed information on incidents and units. - Added legends for timeline and units to improve user experience. - Refactored existing types and interfaces to accommodate new features. --- .../units/_queries/queries.ts | 10 + .../crime-management/units/action.ts | 57 ++++ .../map/controls/top/additional-tooltips.tsx | 1 + .../map/controls/top/crime-tooltips.tsx | 8 +- .../_components/map/controls/top/tooltips.tsx | 2 +- .../app/_components/map/crime-map.tsx | 41 ++- .../_components/map/layers/district-layer.tsx | 57 +++- .../app/_components/map/layers/layers.tsx | 66 +++- .../_components/map/layers/timeline-layer.tsx | 307 ++++++++++++++++++ .../_components/map/layers/units-layer.tsx | 274 ++++++++++++++++ .../map/legends/timeline-legend.tsx | 56 ++++ .../_components/map/legends/units-legend.tsx | 92 ++++++ sigap-website/app/_utils/colors.ts | 85 +++++ sigap-website/app/_utils/time.ts | 157 +++++++++ sigap-website/app/_utils/types/map.ts | 14 +- sigap-website/app/_utils/types/units.ts | 36 ++ 16 files changed, 1227 insertions(+), 36 deletions(-) create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts create mode 100644 sigap-website/app/_components/map/layers/timeline-layer.tsx create mode 100644 sigap-website/app/_components/map/layers/units-layer.tsx create mode 100644 sigap-website/app/_components/map/legends/timeline-legend.tsx create mode 100644 sigap-website/app/_components/map/legends/units-legend.tsx create mode 100644 sigap-website/app/_utils/colors.ts create mode 100644 sigap-website/app/_utils/time.ts create mode 100644 sigap-website/app/_utils/types/units.ts diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts new file mode 100644 index 0000000..608d2ab --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts @@ -0,0 +1,10 @@ +import { IUnits } from '@/app/_utils/types/units'; +import { useQuery } from '@tanstack/react-query'; +import { getUnits } from '../action'; + +export const useGetUnitsQuery = () => { + return useQuery({ + queryKey: ['units'], + queryFn: () => getUnits(), + }); +}; diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts new file mode 100644 index 0000000..1f488ff --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts @@ -0,0 +1,57 @@ +'use server'; + +import { IUnits } from '@/app/_utils/types/units'; +import { getInjection } from '@/di/container'; +import db from '@/prisma/db'; +import { AuthenticationError } from '@/src/entities/errors/auth'; +import { InputParseError } from '@/src/entities/errors/common'; + +export async function getUnits(): Promise { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'District Crime Data', + { recordResponse: true }, + async () => { + try { + const units = await db.units.findMany({ + include: { + districts: { + select: { + name: true, + }, + }, + }, + }); + return units; + } 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.' + ); + } + + 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.' + ); + } + } + ); +} 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 4ef0e8d..68a7bc3 100644 --- a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx @@ -30,6 +30,7 @@ interface AdditionalTooltipsProps { setSelectedCategory: (category: string | "all") => void availableYears?: (number | null)[] categories?: string[] + } export default function AdditionalTooltips({ 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 2025b7d..40f8d9e 100644 --- a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx @@ -2,9 +2,9 @@ import { Button } from "@/app/_components/ui/button" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" -import { AlertTriangle, BarChart2, Car, ChartScatter, Clock, Map, Shield, Users } from "lucide-react" +import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Map, Shield, Users } from "lucide-react" import { ITooltips } from "./tooltips" -import { IconBubble, IconChartBubble } from "@tabler/icons-react" +import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react" // Define the primary crime data controls @@ -12,9 +12,9 @@ const crimeTooltips = [ { id: "incidents" as ITooltips, icon: , label: "All Incidents" }, { id: "heatmap" as ITooltips, icon: , label: "Crime Heatmap" }, { id: "clusters" as ITooltips, icon: , label: "Clustered Incidents" }, - { id: "units" as ITooltips, icon: , label: "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" }, + { id: "timeline" as ITooltips, icon: , label: "Time Analysis" }, ] interface CrimeTooltipsProps { diff --git a/sigap-website/app/_components/map/controls/top/tooltips.tsx b/sigap-website/app/_components/map/controls/top/tooltips.tsx index 194876d..80d396b 100644 --- a/sigap-website/app/_components/map/controls/top/tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/tooltips.tsx @@ -12,7 +12,7 @@ export type ITooltips = // Crime data views | "incidents" | "heatmap" - | "trends" + | "units" | "patrol" | "reports" | "clusters" diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 4ab7e72..54e07d2 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -10,6 +10,8 @@ import { useRef, useState, useCallback, useMemo, useEffect } from "react" import { useFullscreen } from "@/app/_hooks/use-fullscreen" 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 MapSelectors from "./controls/map-selector" @@ -23,6 +25,7 @@ import Tooltips from "./controls/top/tooltips" import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map" import Layers from "./layers/layers" import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old" +import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries" // Updated CrimeIncident type to match the structure in crime_incidents interface ICrimeIncident { @@ -51,6 +54,7 @@ export default function CrimeMap() { const [yearProgress, setYearProgress] = useState(0) const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false) const [isSearchActive, setIsSearchActive] = useState(false) + const [showUnitsLayer, setShowUnitsLayer] = useState(false) const mapContainerRef = useRef(null) @@ -79,6 +83,8 @@ export default function CrimeMap() { error: crimesError } = useGetCrimes() + const { data: fetchedUnits, isLoading } = useGetUnitsQuery() + // Filter crimes based on selected year and month const filteredByYearAndMonth = useMemo(() => { if (!crimes) return [] @@ -323,8 +329,17 @@ export default function CrimeMap() { if (controlId === "search") { setIsSearchActive(prev => !prev) } + + // Toggle units layer visibility when units control is clicked + if (controlId === "units") { + setShowUnitsLayer(true) + } else if (showUnitsLayer) { + setShowUnitsLayer(false) + } } + const showTimelineLayer = activeControl === "timeline"; + return ( @@ -363,22 +378,13 @@ export default function CrimeMap() { {/* Replace the DistrictLayer with the new Layers component */} - {/* */} - {/* Popup for selected incident */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( <> @@ -388,7 +394,6 @@ export default function CrimeMap() { onClose={handlePopupClose} incident={selectedIncident} /> - )} @@ -423,6 +428,20 @@ export default function CrimeMap() { + {isFullscreen && showUnitsLayer && ( +
+ +
+ )} + + {isFullscreen && showTimelineLayer && ( +
+ +
+ )} )} diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index a7000f3..16a8820 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -18,6 +18,8 @@ export default function DistrictFillLineLayer({ focusedDistrictId, setFocusedDistrictId, crimeDataByDistrict, + showFill = true, + activeControl, }: IDistrictLayerProps) { useEffect(() => { if (!map || !visible) return @@ -38,7 +40,10 @@ export default function DistrictFillLineLayer({ // If clicking the same district, deselect it if (focusedDistrictId === districtId) { - setFocusedDistrictId(null) + // Add null check for setFocusedDistrictId + if (setFocusedDistrictId) { + setFocusedDistrictId(null) + } // Reset pitch and bearing with animation map.easeTo({ @@ -65,12 +70,14 @@ export default function DistrictFillLineLayer({ } else if (focusedDistrictId) { // If we're already focusing on a district and clicking a different one, // we need to reset the current one and move to the new one - setFocusedDistrictId(null) + if (setFocusedDistrictId) { + setFocusedDistrictId(null) + } // Wait a moment before selecting the new district to ensure clean transitions setTimeout(() => { const district = processDistrictFeature(feature, e, districtId, crimeDataByDistrict, crimes, year, month) - if (!district) return + if (!district || !setFocusedDistrictId) return setFocusedDistrictId(district.id) @@ -100,7 +107,10 @@ export default function DistrictFillLineLayer({ const focusedFillColorExpression = createFillColorExpression(district.id, crimeDataByDistrict) map.setPaintProperty("district-fill", "fill-color", focusedFillColorExpression as any) - setFocusedDistrictId(district.id) + // Add null check for setFocusedDistrictId + if (setFocusedDistrictId) { + setFocusedDistrictId(district.id) + } // Hide clusters when focusing on a district if (map.getLayer("clusters")) { @@ -146,6 +156,9 @@ export default function DistrictFillLineLayer({ const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict) + // Determine fill opacity based on active control + const fillOpacity = getFillOpacity(activeControl, showFill); + if (!map.getLayer("district-fill")) { map.addLayer( { @@ -155,7 +168,7 @@ export default function DistrictFillLineLayer({ "source-layer": "Districts", paint: { "fill-color": fillColorExpression as any, - "fill-opacity": 0.6, + "fill-opacity": fillOpacity, }, }, firstSymbolId, @@ -193,6 +206,10 @@ export default function DistrictFillLineLayer({ if (map.getLayer("district-fill")) { const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict) map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any) + + // Update fill opacity when active control changes + const fillOpacity = getFillOpacity(activeControl, showFill); + map.setPaintProperty("district-fill", "fill-opacity", fillOpacity); } } } catch (error) { @@ -223,19 +240,43 @@ export default function DistrictFillLineLayer({ crimeDataByDistrict, onClick, setFocusedDistrictId, + showFill, + activeControl, ]) - // Add an effect to update the fill color whenever focusedDistrictId changes + // Add an effect to update the fill color and opacity whenever relevant props change useEffect(() => { if (!map || !map.getLayer("district-fill")) return; try { const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict) map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any) + + // Update fill opacity when active control changes + const fillOpacity = getFillOpacity(activeControl, showFill); + map.setPaintProperty("district-fill", "fill-opacity", fillOpacity); } catch (error) { - console.error("Error updating district fill colors:", error) + console.error("Error updating district fill colors or opacity:", error) } - }, [map, focusedDistrictId, crimeDataByDistrict]) + }, [map, focusedDistrictId, crimeDataByDistrict, activeControl, showFill]) return null } + +// Helper function to determine fill opacity based on active control +function getFillOpacity(activeControl?: string, showFill?: boolean): number { + if (!showFill) return 0; + + // Full opacity for incidents and clusters + if (activeControl === "incidents" || activeControl === "clusters") { + return 0.6; + } + + // Low opacity for timeline to show markers but still see district boundaries + if (activeControl === "timeline") { + return 0.1; + } + + // No fill for other controls, but keep boundaries visible + return 0; +} diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index c66163a..8639dd6 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useRef, useEffect } from "react" +import { useState, useRef, useEffect, useCallback } from "react" import { useMap } from "react-map-gl/mapbox" import { BASE_BEARING, BASE_PITCH, BASE_ZOOM, MAPBOX_TILESET_ID } from "@/app/_utils/const/map" import DistrictPopup from "../pop-up/district-popup" @@ -8,6 +8,7 @@ import DistrictExtrusionLayer from "./district-extrusion-layer" import ClusterLayer from "./cluster-layer" import HeatmapLayer from "./heatmap-layer" import DistrictLayer from "./district-layer-old" +import TimelineLayer from "./timeline-layer" import type { ICrimes } from "@/app/_utils/types/crimes" import { IDistrictFeature } from "@/app/_utils/types/map" @@ -16,6 +17,9 @@ import UnclusteredPointLayer from "./uncluster-layer" import FlyToHandler from "../fly-to" import { toast } from "sonner" import { ITooltips } from "../controls/top/tooltips" +import { IUnits } from "@/app/_utils/types/units" +import UnitsLayer from "./units-layer" +import DistrictFillLineLayer from "./district-layer" // District layer props export interface IDistrictLayerProps { @@ -25,12 +29,14 @@ export interface IDistrictLayerProps { month: string filterCategory: string | "all" crimes: ICrimes[] + units?: IUnits[] tilesetId?: string } interface LayersProps { visible?: boolean; crimes: ICrimes[]; + units?: IUnits[]; year: string; month: string; filterCategory: string | "all"; @@ -41,6 +47,7 @@ interface LayersProps { export default function Layers({ visible = true, crimes, + units, year, month, filterCategory, @@ -202,23 +209,51 @@ export default function Layers({ } }, [crimes, filterCategory, year, month, crimeDataByDistrict]) + // Make sure we have a defined handler for setFocusedDistrictId + const handleSetFocusedDistrictId = useCallback((id: string | null) => { + setFocusedDistrictId(id); + }, []); + if (!visible) return null // Determine which layers should be visible based on the active control const showDistrictLayer = activeControl === "incidents"; const showHeatmapLayer = activeControl === "heatmap"; const showClustersLayer = activeControl === "clusters"; + const showUnitsLayer = activeControl === "units"; + const showTimelineLayer = activeControl === "timeline"; + + // District fill should only be visible for incidents and clusters + const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"; return ( <> + {/* Ensure we pass the proper defined handler */} + + {/* Standard District Layer with incident points */} - + visible={true} // Keep the layer but control fill opacity + showFill={showDistrictFill} + activeControl={activeControl} + /> */} {/* Heatmap Layer */} + {/* Timeline Layer - show average incident time per district */} + + + {/* Units Layer - show police stations and connection lines */} + + {/* District base layer is always needed */} - {/* Unclustered Points Layer - hide when in cluster mode */} + {/* Unclustered Points Layer - hide when in cluster mode or units mode */} (null) + const [popup, setPopup] = useState(null) + + // Process district data to extract average incident times + const districtTimeData = useMemo(() => { + // Group incidents by district + const districtGroups = new Map, + center: [number, number] + }>() + + crimes.forEach(crime => { + if (!crime.districts || !crime.district_id) return + + // Initialize district group if not exists + if (!districtGroups.has(crime.district_id)) { + // Find a central location for the district from any incident + const centerIncident = crime.crime_incidents.find(inc => + inc.locations?.latitude && inc.locations?.longitude + ) + + const center: [number, number] = centerIncident + ? [centerIncident.locations.longitude, centerIncident.locations.latitude] + : [0, 0] + + districtGroups.set(crime.district_id, { + districtId: crime.district_id, + districtName: crime.districts.name, + incidents: [], + center + }) + } + + // Add valid incidents to the district group + crime.crime_incidents.forEach(incident => { + if (!incident.timestamp) return + if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return + + const group = districtGroups.get(crime.district_id) + if (group) { + group.incidents.push({ + timestamp: new Date(incident.timestamp), + category: incident.crime_categories.name + }) + } + }) + }) + + // Calculate average time for each district + const result = Array.from(districtGroups.values()) + .filter(group => group.incidents.length > 0 && group.center[0] !== 0) + .map(group => { + const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map(inc => inc.timestamp)) + + return { + id: group.districtId, + name: group.districtName, + center: group.center, + avgHour: avgTimeInfo.hour, + avgMinute: avgTimeInfo.minute, + formattedTime: avgTimeInfo.formattedTime, + timeDescription: avgTimeInfo.description, + totalIncidents: group.incidents.length, + // Categorize by morning, afternoon, evening, night + timeOfDay: avgTimeInfo.timeOfDay, + // Additional statistics + earliestTime: format(avgTimeInfo.earliest, 'p'), + latestTime: format(avgTimeInfo.latest, 'p'), + mostFrequentHour: avgTimeInfo.mostFrequentHour, + // Group incidents by category for the popup + categoryCounts: group.incidents.reduce((acc, inc) => { + acc[inc.category] = (acc[inc.category] || 0) + 1 + return acc + }, {} as Record) + } + }) + + return result + }, [crimes, filterCategory]) + + // Convert processed data to GeoJSON for display + const timelineGeoJSON = useMemo(() => { + return { + type: "FeatureCollection" as const, + features: districtTimeData.map(district => ({ + type: "Feature" as const, + properties: { + id: district.id, + name: district.name, + avgTime: district.formattedTime, + timeDescription: district.timeDescription, + totalIncidents: district.totalIncidents, + timeOfDay: district.timeOfDay + }, + geometry: { + type: "Point" as const, + coordinates: district.center + } + })) + } + }, [districtTimeData]) + + // Style time markers based on time of day + const getTimeMarkerColor = (timeOfDay: string) => { + switch (timeOfDay) { + case 'morning': return '#FFEB3B' // yellow + case 'afternoon': return '#FF9800' // orange + case 'evening': return '#3F51B5' // indigo + case 'night': return '#263238' // dark blue-grey + default: return '#4CAF50' // green fallback + } + } + + // Event handlers + useEffect(() => { + if (!map || !visible) return + + const handleTimeMarkerClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { + if (!e.features || e.features.length === 0) return + + const feature = e.features[0] + const props = feature.properties + if (!props) return + + // Get the corresponding district data for detailed info + const districtData = districtTimeData.find(d => d.id === props.id) + if (!districtData) return + + // Remove existing popup if any + if (popup) popup.remove() + + // Create HTML content for popup + const categoriesHtml = Object.entries(districtData.categoryCounts) + .sort(([, countA], [, countB]) => countB - countA) + .slice(0, 5) // Top 5 categories + .map(([category, count]) => + `
+ ${category} + ${count} +
` + ).join('') + + // Create popup + const newPopup = new mapboxgl.Popup({ closeButton: true, closeOnClick: false }) + .setLngLat(feature.geometry.type === 'Point' ? (feature.geometry as GeoJSON.Point).coordinates as [number, number] : [0, 0]) + .setHTML(` +
+
${districtData.name}
+
+ Average incident time +
+
${districtData.formattedTime}
+
${districtData.timeDescription}
+
+
Based on ${districtData.totalIncidents} incidents
+
+ +
+
+ Earliest incident: + ${districtData.earliestTime} +
+
+ Latest incident: + ${districtData.latestTime} +
+
+ +
+
Top incident types:
+ ${categoriesHtml} +
+
+ `) + .addTo(map) + + // Store popup reference + setPopup(newPopup) + setSelectedDistrict(props.id) + + // Remove popup when closed + newPopup.on('close', () => { + setPopup(null) + setSelectedDistrict(null) + }) + } + + // Set up event handlers + const handleMouseEnter = () => { + if (map) map.getCanvas().style.cursor = 'pointer' + } + + const handleMouseLeave = () => { + if (map) map.getCanvas().style.cursor = '' + } + + // Add event listeners + if (map.getLayer('timeline-markers')) { + map.on('click', 'timeline-markers', handleTimeMarkerClick) + map.on('mouseenter', 'timeline-markers', handleMouseEnter) + map.on('mouseleave', 'timeline-markers', handleMouseLeave) + } + + return () => { + // Clean up event listeners + if (map) { + map.off('click', 'timeline-markers', handleTimeMarkerClick) + map.off('mouseenter', 'timeline-markers', handleMouseEnter) + map.off('mouseleave', 'timeline-markers', handleMouseLeave) + + // Remove popup if it exists + if (popup) { + popup.remove() + setPopup(null) + } + } + } + }, [map, visible, districtTimeData, popup]) + + // Clean up popup on unmount or when visibility changes + useEffect(() => { + if (!visible && popup) { + popup.remove() + setPopup(null) + setSelectedDistrict(null) + } + }, [visible, popup]) + + if (!visible) return null + + return ( + + {/* Time marker circles */} + + + {/* Time labels */} + + + ) +} diff --git a/sigap-website/app/_components/map/layers/units-layer.tsx b/sigap-website/app/_components/map/layers/units-layer.tsx new file mode 100644 index 0000000..b39be0b --- /dev/null +++ b/sigap-website/app/_components/map/layers/units-layer.tsx @@ -0,0 +1,274 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Layer, Source } from "react-map-gl/mapbox" +import { ICrimes } from "@/app/_utils/types/crimes" +import { IUnits } from "@/app/_utils/types/units" +import mapboxgl from 'mapbox-gl' +import { generateCategoryColorMap, getCategoryColor } from '@/app/_utils/colors' + +interface UnitsLayerProps { + crimes: ICrimes[] + units?: IUnits[] + filterCategory: string | "all" + visible?: boolean + map?: mapboxgl.Map | null +} + +export default function UnitsLayer({ + crimes, + units = [], + filterCategory, + visible = false, + map +}: UnitsLayerProps) { + const [loadedUnits, setLoadedUnits] = useState([]) + const loadedUnitsRef = useRef([]) + + // Use either provided units or loaded units + const unitsData = useMemo(() => { + return units.length > 0 ? units : (loadedUnits || []) + }, [units, loadedUnits]) + + // Extract all unique crime categories for color generation + const uniqueCategories = useMemo(() => { + const categories = new Set(); + crimes.forEach(crime => { + crime.crime_incidents.forEach(incident => { + if (incident.crime_categories?.name) { + categories.add(incident.crime_categories.name); + } + }); + }); + return Array.from(categories); + }, [crimes]); + + // Generate color map for all categories + const categoryColorMap = useMemo(() => { + return generateCategoryColorMap(uniqueCategories); + }, [uniqueCategories]); + + // Process units data to GeoJSON format + const unitsGeoJSON = useMemo(() => { + return { + type: "FeatureCollection" as const, + features: unitsData.map(unit => ({ + type: "Feature" as const, + properties: { + id: unit.code_unit, + name: unit.name, + address: unit.address, + phone: unit.phone, + type: unit.type, + district: unit.districts?.name || "", + district_id: unit.district_id, + }, + geometry: { + type: "Point" as const, + coordinates: [unit.longitude || 0, unit.latitude || 0] + } + })).filter(feature => + feature.geometry.coordinates[0] !== 0 && + feature.geometry.coordinates[1] !== 0 + ) + } + }, [unitsData]) + + // Create lines between units and incidents within their districts + const connectionLinesGeoJSON = useMemo(() => { + if (!unitsData.length || !crimes.length) return { + type: "FeatureCollection" as const, + features: [] + } + + // Map district IDs to their units + const districtUnitsMap = new Map() + + unitsData.forEach(unit => { + if (!unit.district_id || !unit.longitude || !unit.latitude) return + + if (!districtUnitsMap.has(unit.district_id)) { + districtUnitsMap.set(unit.district_id, []) + } + districtUnitsMap.get(unit.district_id)!.push(unit) + }) + + // Create lines from units to incidents in their district + const lineFeatures: any[] = [] + + crimes.forEach(crime => { + // Get all units in this district + const districtUnits = districtUnitsMap.get(crime.district_id) || [] + if (!districtUnits.length) return + + // For each incident in this district + crime.crime_incidents.forEach(incident => { + // Skip incidents without location data or filtered by category + if ( + !incident.locations?.latitude || + !incident.locations?.longitude || + (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) + ) return + + // Create a line from each unit in this district to this incident + districtUnits.forEach(unit => { + if (!unit.longitude || !unit.latitude) return + + lineFeatures.push({ + type: "Feature" as const, + properties: { + unit_id: unit.code_unit, + unit_name: unit.name, + incident_id: incident.id, + district_id: crime.district_id, + district_name: crime.districts.name, + category: incident.crime_categories.name, + lineColor: categoryColorMap[incident.crime_categories.name] || '#22c55e', + }, + geometry: { + type: "LineString" as const, + coordinates: [ + [unit.longitude, unit.latitude], + [incident.locations.longitude, incident.locations.latitude] + ] + } + }) + }) + }) + }) + + return { + type: "FeatureCollection" as const, + features: lineFeatures + } + }, [unitsData, crimes, filterCategory, categoryColorMap]) + + // Map click handler code and the rest remains the same... + useEffect(() => { + if (!map || !visible) return + + const handleUnitClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { + if (!e.features || e.features.length === 0) return + + const feature = e.features[0] + const properties = feature.properties + + if (!properties) return + + // Create a popup for the unit + const popup = new mapboxgl.Popup() + .setLngLat(feature.geometry.type === 'Point' ? + (feature.geometry as any).coordinates as [number, number] : + [0, 0]) // Fallback coordinates if not a Point geometry + .setHTML(` +
+

${properties.name}

+

${properties.type}

+

${properties.address || 'No address provided'}

+

Staff: ${properties.staff_count || 'N/A'}

+

Phone: ${properties.phone || 'N/A'}

+

District: ${properties.district || 'N/A'}

+
+ `) + .addTo(map) + + // Highlight the connected lines for this unit + if (map.getLayer('units-connection-lines')) { + map.setFilter('units-connection-lines', [ + '==', + ['get', 'unit_id'], + properties.id + ]) + } + + // When popup closes, reset the lines filter + popup.on('close', () => { + if (map.getLayer('units-connection-lines')) { + map.setFilter('units-connection-lines', ['has', 'unit_id']) + } + }) + } + + // Define event handlers that can be referenced for both adding and removing + const handleMouseEnter = () => { + map.getCanvas().style.cursor = 'pointer' + } + + const handleMouseLeave = () => { + map.getCanvas().style.cursor = '' + } + + // Add click event for units-points layer + if (map.getLayer('units-points')) { + map.on('click', 'units-points', handleUnitClick) + + // Change cursor on hover + map.on('mouseenter', 'units-points', handleMouseEnter) + map.on('mouseleave', 'units-points', handleMouseLeave) + } + + return () => { + if (map.getLayer('units-points')) { + map.off('click', 'units-points', handleUnitClick) + map.off('mouseenter', 'units-points', handleMouseEnter) + map.off('mouseleave', 'units-points', handleMouseLeave) + } + } + }, [map, visible]) + + if (!visible) return null + + return ( + <> + {/* Units Points */} + + + + {/* Units Symbols */} + + + + {/* Connection Lines */} + + + + + ) +} diff --git a/sigap-website/app/_components/map/legends/timeline-legend.tsx b/sigap-website/app/_components/map/legends/timeline-legend.tsx new file mode 100644 index 0000000..c81f34d --- /dev/null +++ b/sigap-website/app/_components/map/legends/timeline-legend.tsx @@ -0,0 +1,56 @@ +"use client" + +import { Card } from "@/app/_components/ui/card" +import { Clock, Moon, Sun } from "lucide-react" + +interface TimelineLegendProps { + position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" +} + +export default function TimelineLegend({ + position = "bottom-right" +}: TimelineLegendProps) { + const positionClasses = { + "top-right": "top-4 right-4", + "top-left": "top-4 left-4", + "bottom-right": "bottom-4 right-4", + "bottom-left": "bottom-4 left-4" + } + + return ( + +
+

+ + Incident Time Patterns +

+ +
+
+
+ Morning (5am-12pm) +
+ +
+
+ Afternoon (12pm-5pm) +
+ +
+
+ Evening (5pm-9pm) +
+ +
+
+ Night (9pm-5am) +
+
+ +
+ Circles show average incident time. Click for details. +
+
+
+ ) +} diff --git a/sigap-website/app/_components/map/legends/units-legend.tsx b/sigap-website/app/_components/map/legends/units-legend.tsx new file mode 100644 index 0000000..f366ee6 --- /dev/null +++ b/sigap-website/app/_components/map/legends/units-legend.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useState, useMemo } from "react" +import { Card } from "@/app/_components/ui/card" +import { Button } from "@/app/_components/ui/button" +import { ChevronDown, ChevronUp, X } from "lucide-react" +import { getCategoryColor } from "@/app/_utils/colors" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" +import { ScrollArea } from "@/app/_components/ui/scroll-area" + +interface UnitsLegendProps { + categories: string[] + onClose?: () => void + position?: "top-right" | "top-left" | "bottom-right" | "bottom-left" +} + +export default function UnitsLegend({ + categories, + onClose, + position = "bottom-right" +}: UnitsLegendProps) { + const [collapsed, setCollapsed] = useState(false) + + const positionClasses = { + "top-right": "top-4 right-4", + "top-left": "top-4 left-4", + "bottom-right": "bottom-4 right-4", + "bottom-left": "bottom-4 left-4", + } + + const sortedCategories = useMemo(() => { + return [...categories].sort((a, b) => a.localeCompare(b)) + }, [categories]) + + if (categories.length === 0) return null + + return ( + +
+

Crime Categories

+
+ + + {onClose && ( + + )} +
+
+ + {!collapsed && ( + +
+ + {sortedCategories.map((category) => ( + + +
+
+ {category} +
+ + +

{category}

+
+ + ))} + +
+ + )} + + ) +} diff --git a/sigap-website/app/_utils/colors.ts b/sigap-website/app/_utils/colors.ts new file mode 100644 index 0000000..187ce31 --- /dev/null +++ b/sigap-website/app/_utils/colors.ts @@ -0,0 +1,85 @@ +/** + * This utility generates unique colors for crime categories + * and ensures that they remain consistent across renders. + */ + +// Color cache to ensure consistent colors for the same category +const colorCache: Record = {}; + +// HSL to hex conversion utility +const hslToHex = (h: number, s: number, l: number): string => { + const a = s * Math.min(l, 1 - l); + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color) + .toString(16) + .padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}`; +}; + +// Golden ratio approximation for color spacing +const GOLDEN_RATIO = 0.618033988749895; + +/** + * Generates a visually distinct color for a category. + * Uses the HSL color space to ensure good distribution and saturation. + * + * @param category The name of the category to generate color for + * @param seed Optional random seed for deterministic results + * @returns Hex color code + */ +export const getCategoryColor = (category: string, seed = 0.5): string => { + // Return cached color if exists + if (colorCache[category]) { + return colorCache[category]; + } + + // Generate a hash from the string + const hash = category.split('').reduce((acc, char) => { + return acc + char.charCodeAt(0); + }, 0); + + // Use the hash to deterministically generate a hue value + let hue = (hash * GOLDEN_RATIO + seed) % 1; + hue = Math.floor(hue * 360); + + // Use high saturation and medium lightness for vibrant but not too bright colors + const saturation = 0.7 + (hash % 30) / 100; // 0.7-0.99 + const lightness = 0.5 + (hash % 20) / 100; // 0.5-0.69 + + // Convert to hex and cache + const color = hslToHex(hue, saturation, lightness); + colorCache[category] = color; + + return color; +}; + +/** + * Pre-generates colors for a list of categories. + * Useful for ensuring colors are as distinct as possible across many categories. + * + * @param categories List of category names + * @returns Object mapping category names to colors + */ +export const generateCategoryColorMap = ( + categories: string[] +): Record => { + const colorMap: Record = {}; + + // For maximum distinctiveness, space the hues evenly around the color wheel + const increment = 1 / categories.length; + + categories.forEach((category, index) => { + const hue = (index * increment) % 1; + const saturation = 0.7 + ((index * 13) % 30) / 100; + const lightness = 0.5 + ((index * 11) % 20) / 100; + + const color = hslToHex(hue * 360, saturation, lightness); + colorMap[category] = color; + colorCache[category] = color; // Also cache it + }); + + return colorMap; +}; diff --git a/sigap-website/app/_utils/time.ts b/sigap-website/app/_utils/time.ts new file mode 100644 index 0000000..96accba --- /dev/null +++ b/sigap-website/app/_utils/time.ts @@ -0,0 +1,157 @@ +import { format, formatDistanceToNow } from 'date-fns'; + +/** + * Calculate the average time of day from a list of timestamps + */ +export function calculateAverageTimeOfDay(timestamps: Date[]) { + if (!timestamps.length) { + return { + hour: 0, + minute: 0, + formattedTime: '00:00', + description: 'No data', + timeOfDay: 'unknown', + earliest: new Date(), + latest: new Date(), + mostFrequentHour: 0, + }; + } + + // Extract hours and minutes, convert to minutes since midnight + let totalMinutes = 0; + const minutesArray: number[] = []; + const hours: number[] = new Array(24).fill(0); // For hour frequency + + let earliest = new Date(timestamps[0]); + let latest = new Date(timestamps[0]); + + timestamps.forEach((timestamp) => { + const date = new Date(timestamp); + + // Update earliest and latest + if (date < earliest) earliest = new Date(date); + if (date > latest) latest = new Date(date); + + const hour = date.getHours(); + const minute = date.getMinutes(); + + // Track hour frequency + hours[hour]++; + + // Convert to minutes since midnight + const minutesSinceMidnight = hour * 60 + minute; + minutesArray.push(minutesSinceMidnight); + totalMinutes += minutesSinceMidnight; + }); + + // Find most frequent hour + let mostFrequentHour = 0; + let maxFrequency = 0; + hours.forEach((freq, hour) => { + if (freq > maxFrequency) { + mostFrequentHour = hour; + maxFrequency = freq; + } + }); + + // Need to handle the circular nature of time + // (e.g., average of 23:00 and 01:00 should be around midnight, not noon) + minutesArray.sort((a, b) => a - b); + + // Check if we have times spanning across midnight + let useSortedMedian = false; + for (let i = 0; i < minutesArray.length - 1; i++) { + if (minutesArray[i + 1] - minutesArray[i] > 720) { + // More than 12 hours apart + useSortedMedian = true; + break; + } + } + + let avgMinutesSinceMidnight; + + if (useSortedMedian) { + // Use median to avoid the midnight crossing issue + const mid = Math.floor(minutesArray.length / 2); + avgMinutesSinceMidnight = + minutesArray.length % 2 === 0 + ? (minutesArray[mid - 1] + minutesArray[mid]) / 2 + : minutesArray[mid]; + } else { + // Simple average works when times are clustered + avgMinutesSinceMidnight = totalMinutes / timestamps.length; + } + + // Convert back to hours and minutes + const avgHour = Math.floor(avgMinutesSinceMidnight / 60) % 24; + const avgMinute = Math.floor(avgMinutesSinceMidnight % 60); + + // Format time nicely + const formattedTime = `${avgHour.toString().padStart(2, '0')}:${avgMinute.toString().padStart(2, '0')}`; + + // Determine time of day + let timeOfDay: string; + let description: string; + + if (avgHour >= 5 && avgHour < 12) { + timeOfDay = 'morning'; + description = 'Morning'; + } else if (avgHour >= 12 && avgHour < 17) { + timeOfDay = 'afternoon'; + description = 'Afternoon'; + } else if (avgHour >= 17 && avgHour < 21) { + timeOfDay = 'evening'; + description = 'Evening'; + } else { + timeOfDay = 'night'; + description = 'Night'; + } + + return { + hour: avgHour, + minute: avgMinute, + formattedTime, + description, + timeOfDay, + earliest, + latest, + mostFrequentHour, + }; +} + +/** + * Format a timestamp as a relative time (e.g., "2 hours ago") + */ +export function formatRelativeTime(timestamp: Date | string | number): string { + try { + const date = new Date(timestamp); + return formatDistanceToNow(date, { addSuffix: true }); + } catch (e) { + return 'Invalid date'; + } +} + +/** + * Group timestamps by hour of day + */ +export function getHourDistribution(timestamps: Date[]): number[] { + const hours = new Array(24).fill(0); + + timestamps.forEach((timestamp) => { + const date = new Date(timestamp); + const hour = date.getHours(); + hours[hour]++; + }); + + return hours; +} + +/** + * Get color for time of day visualization + */ +export function getTimeColor(hour: number): string { + if (hour >= 5 && hour < 12) return '#FFEB3B'; // morning - yellow + if (hour >= 12 && hour < 17) return '#FF9800'; // afternoon - orange + if (hour >= 17 && hour < 21) return '#3F51B5'; // evening - indigo + return '#263238'; // night - dark blue-grey +} diff --git a/sigap-website/app/_utils/types/map.ts b/sigap-website/app/_utils/types/map.ts index 08d9803..a76a2a4 100644 --- a/sigap-website/app/_utils/types/map.ts +++ b/sigap-website/app/_utils/types/map.ts @@ -65,18 +65,20 @@ export interface IBaseLayerProps { } // District layer props -export interface IDistrictLayerProps extends IBaseLayerProps { +export interface IDistrictLayerProps { + map: mapboxgl.Map | null; + visible?: boolean; + showFill?: boolean; onClick?: (feature: IDistrictFeature) => void; year: string; month: string; filterCategory: string | 'all'; crimes: ICrimes[]; + tilesetId?: string; + activeControl?: string; focusedDistrictId: string | null; - setFocusedDistrictId: (id: string | null) => void; - crimeDataByDistrict: Record< - string, - { number_of_crime?: number; level?: $Enums.crime_rates } - >; + setFocusedDistrictId?: (id: string | null) => void; + crimeDataByDistrict?: any; } // Extrusion layer props diff --git a/sigap-website/app/_utils/types/units.ts b/sigap-website/app/_utils/types/units.ts new file mode 100644 index 0000000..8d6ac8e --- /dev/null +++ b/sigap-website/app/_utils/types/units.ts @@ -0,0 +1,36 @@ +import { $Enums, units } from '@prisma/client'; + +export interface IUnits { + district_id: string; + created_at: Date | null; + updated_at: Date | null; + name: string; + description: string | null; + type: $Enums.unit_type; + address: string | null; + latitude: number; + longitude: number; + land_area: number | null; + code_unit: string; + phone: string | null; + districts: { + name: string; + }; +} + +// export interface IUnits { +// id: string; +// name: string; +// type: string; +// address?: string | null; +// latitude?: number | null; +// longitude?: number | null; +// district_id: string; +// staff_count?: number | null; +// phone?: string | null; +// created_at?: Date; +// updated_at?: Date; +// districts?: { +// name: string; +// }; +// }