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.
This commit is contained in:
vergiLgood1 2025-05-05 10:38:11 +07:00
parent b0db61a9ff
commit a19e8ec32d
16 changed files with 1227 additions and 36 deletions

View File

@ -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<IUnits[]>({
queryKey: ['units'],
queryFn: () => getUnits(),
});
};

View File

@ -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<IUnits[]> {
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.'
);
}
}
);
}

View File

@ -30,6 +30,7 @@ interface AdditionalTooltipsProps {
setSelectedCategory: (category: string | "all") => void
availableYears?: (number | null)[]
categories?: string[]
}
export default function AdditionalTooltips({

View File

@ -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: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
{ id: "units" as ITooltips, icon: <BarChart2 size={20} />, label: "Units" },
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
{ id: "timeline" as ITooltips, icon: <Clock size={20} />, label: "Time Analysis" },
{ id: "timeline" as ITooltips, icon: <IconClock size={20} />, label: "Time Analysis" },
]
interface CrimeTooltipsProps {

View File

@ -12,7 +12,7 @@ export type ITooltips =
// Crime data views
| "incidents"
| "heatmap"
| "trends"
| "units"
| "patrol"
| "reports"
| "clusters"

View File

@ -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<HTMLDivElement>(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,7 +329,16 @@ 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 (
<Card className="w-full p-0 border-none shadow-none h-96">
@ -363,22 +378,13 @@ export default function CrimeMap() {
{/* Replace the DistrictLayer with the new Layers component */}
<Layers
crimes={filteredCrimes || []}
units={fetchedUnits || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
/>
{/* <DistrictLayer
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
selectedDistrict={selectedDistrict}
setSelectedDistrict={setSelectedDistrict}
/> */}
{/* 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() {
<MapLegend position="bottom-right" />
</div>
{isFullscreen && showUnitsLayer && (
<div className="absolute bottom-40 right-0 z-10 p-2">
<UnitsLegend
categories={categories}
position="bottom-right"
/>
</div>
)}
{isFullscreen && showTimelineLayer && (
<div className="absolute bottom-40 right-0 z-10 p-2">
<TimelineLegend position="bottom-right" />
</div>
)}
</>
)}

View File

@ -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) {
// 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
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)
// 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;
}

View File

@ -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 */}
<DistrictFillLineLayer
visible={true}
map={mapboxMap}
year={year}
month={month}
filterCategory={filterCategory}
crimes={crimes}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
setFocusedDistrictId={handleSetFocusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
showFill={showDistrictFill}
activeControl={activeControl}
/>
{/* Standard District Layer with incident points */}
<DistrictLayer
{/* <DistrictLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={showDistrictLayer}
/>
visible={true} // Keep the layer but control fill opacity
showFill={showDistrictFill}
activeControl={activeControl}
/> */}
{/* Heatmap Layer */}
<HeatmapLayer
@ -229,6 +264,25 @@ export default function Layers({
visible={showHeatmapLayer}
/>
{/* Timeline Layer - show average incident time per district */}
<TimelineLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={showTimelineLayer}
map={mapboxMap}
/>
{/* Units Layer - show police stations and connection lines */}
<UnitsLayer
crimes={crimes}
units={units}
filterCategory={filterCategory}
visible={showUnitsLayer}
map={mapboxMap}
/>
{/* District base layer is always needed */}
<DistrictExtrusionLayer
visible={visible}
@ -249,9 +303,9 @@ export default function Layers({
showClusters={showClustersLayer}
/>
{/* Unclustered Points Layer - hide when in cluster mode */}
{/* Unclustered Points Layer - hide when in cluster mode or units mode */}
<UnclusteredPointLayer
visible={visible && !showClustersLayer && showDistrictLayer}
visible={visible && !showClustersLayer && showDistrictLayer && !showUnitsLayer}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}

View File

@ -0,0 +1,307 @@
"use client"
import { useEffect, useMemo, useState } from 'react'
import { Layer, Source } from "react-map-gl/mapbox"
import { ICrimes } from "@/app/_utils/types/crimes"
import mapboxgl from 'mapbox-gl'
import { format } from 'date-fns'
import { calculateAverageTimeOfDay } from '@/app/_utils/time'
interface TimelineLayerProps {
crimes: ICrimes[]
year: string
month: string
filterCategory: string | "all"
visible?: boolean
map?: mapboxgl.Map | null
}
export default function TimelineLayer({
crimes,
year,
month,
filterCategory,
visible = false,
map
}: TimelineLayerProps) {
// State to hold the currently selected district for popup display
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null)
const [popup, setPopup] = useState<mapboxgl.Popup | null>(null)
// Process district data to extract average incident times
const districtTimeData = useMemo(() => {
// Group incidents by district
const districtGroups = new Map<string, {
districtId: string,
districtName: string,
incidents: Array<{ timestamp: Date, category: string }>,
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<string, number>)
}
})
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]) =>
`<div class="flex justify-between mb-1">
<span class="text-xs">${category}</span>
<span class="text-xs font-semibold">${count}</span>
</div>`
).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(`
<div class="p-3">
<div class="font-bold text-base mb-2">${districtData.name}</div>
<div class="mb-3">
<span class="text-xs text-gray-600">Average incident time</span>
<div class="flex items-center gap-2">
<div class="text-lg font-bold">${districtData.formattedTime}</div>
<div class="text-xs bg-gray-200 rounded px-2 py-0.5">${districtData.timeDescription}</div>
</div>
<div class="text-xs mt-1">Based on ${districtData.totalIncidents} incidents</div>
</div>
<div class="text-sm my-2">
<div class="flex justify-between mb-1">
<span>Earliest incident:</span>
<span class="font-medium">${districtData.earliestTime}</span>
</div>
<div class="flex justify-between">
<span>Latest incident:</span>
<span class="font-medium">${districtData.latestTime}</span>
</div>
</div>
<div class="border-t border-gray-200 my-2 pt-2">
<div class="text-xs font-medium mb-1">Top incident types:</div>
${categoriesHtml}
</div>
</div>
`)
.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 (
<Source id="timeline-data" type="geojson" data={timelineGeoJSON}>
{/* Time marker circles */}
<Layer
id="timeline-markers"
type="circle"
paint={{
'circle-color': [
'match',
['get', 'timeOfDay'],
'morning', '#FFEB3B',
'afternoon', '#FF9800',
'evening', '#3F51B5',
'night', '#263238',
'#4CAF50' // Default color
],
'circle-radius': 12,
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.9
}}
/>
{/* Time labels */}
<Layer
id="timeline-labels"
type="symbol"
layout={{
'text-field': '{avgTime}',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 10,
'text-anchor': 'center',
'text-allow-overlap': true
}}
paint={{
'text-color': [
'match',
['get', 'timeOfDay'],
'night', '#FFFFFF',
'evening', '#FFFFFF',
'#000000' // Default text color
]
}}
/>
</Source>
)
}

View File

@ -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<IUnits[]>([])
const loadedUnitsRef = useRef<IUnits[]>([])
// 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<string>();
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<string, IUnits[]>()
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(`
<div class="p-2">
<h3 class="font-bold text-base">${properties.name}</h3>
<p class="text-sm">${properties.type}</p>
<p class="text-sm">${properties.address || 'No address provided'}</p>
<p class="text-xs mt-2">Staff: ${properties.staff_count || 'N/A'}</p>
<p class="text-xs">Phone: ${properties.phone || 'N/A'}</p>
<p class="text-xs">District: ${properties.district || 'N/A'}</p>
</div>
`)
.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 */}
<Source id="units-source" type="geojson" data={unitsGeoJSON}>
<Layer
id="units-points"
type="circle"
paint={{
'circle-radius': 8,
'circle-color': '#1e40af', // Deep blue for police units
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.8
}}
/>
{/* Units Symbols */}
<Layer
id="units-symbols"
type="symbol"
layout={{
'text-field': ['get', 'name'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12,
'text-offset': [0, -2],
'text-anchor': 'bottom',
'text-allow-overlap': false,
'text-ignore-placement': false
}}
paint={{
'text-color': '#ffffff',
'text-halo-color': '#000000',
'text-halo-width': 1
}}
/>
</Source>
{/* Connection Lines */}
<Source id="units-lines-source" type="geojson" data={connectionLinesGeoJSON}>
<Layer
id="units-connection-lines"
type="line"
paint={{
// Use the pre-computed color stored in the properties
'line-color': ['get', 'lineColor'],
'line-width': 1.5,
'line-opacity': 0.7,
'line-dasharray': [1, 2] // Dashed line
}}
/>
</Source>
</>
)
}

View File

@ -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 (
<Card className={`absolute z-10 bg-black/80 border-gray-700 shadow-lg p-3 ${positionClasses[position]}`}>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium text-white mb-2 flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Incident Time Patterns</span>
</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#FFEB3B]"></div>
<span className="text-xs text-white">Morning (5am-12pm)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#FF9800]"></div>
<span className="text-xs text-white">Afternoon (12pm-5pm)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#3F51B5]"></div>
<span className="text-xs text-white">Evening (5pm-9pm)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#263238]"></div>
<span className="text-xs text-white">Night (9pm-5am)</span>
</div>
</div>
<div className="text-xs text-gray-400 mt-1">
Circles show average incident time. Click for details.
</div>
</div>
</Card>
)
}

View File

@ -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 (
<Card className={`absolute z-10 bg-black/80 border-gray-700 shadow-lg overflow-hidden w-64 ${positionClasses[position]}`}>
<div className="p-2 border-b border-gray-700 flex items-center justify-between">
<h3 className="text-sm font-medium text-white">Crime Categories</h3>
<div className="flex gap-1">
<Button
variant="ghost"
size="xs"
className="h-6 w-6 p-0 text-white hover:bg-white/20"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
<span className="sr-only">{collapsed ? "Expand" : "Collapse"}</span>
</Button>
{onClose && (
<Button
variant="ghost"
size="xs"
className="h-6 w-6 p-0 text-white hover:bg-white/20"
onClick={onClose}
>
<X size={14} />
<span className="sr-only">Close</span>
</Button>
)}
</div>
</div>
{!collapsed && (
<ScrollArea className="h-64">
<div className="p-2 grid grid-cols-1 gap-1">
<TooltipProvider>
{sortedCategories.map((category) => (
<Tooltip key={category}>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-xs text-white p-1 rounded hover:bg-white/10">
<div
className="w-4 h-2 flex-shrink-0 rounded-sm"
style={{ backgroundColor: getCategoryColor(category) }}
/>
<span className="truncate">{category}</span>
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p>{category}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
</ScrollArea>
)}
</Card>
)
}

View File

@ -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<string, string> = {};
// 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<string, string> => {
const colorMap: Record<string, string> = {};
// 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;
};

View File

@ -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
}

View File

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

View File

@ -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;
// };
// }