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:
parent
b0db61a9ff
commit
a19e8ec32d
|
@ -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(),
|
||||
});
|
||||
};
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -30,6 +30,7 @@ interface AdditionalTooltipsProps {
|
|||
setSelectedCategory: (category: string | "all") => void
|
||||
availableYears?: (number | null)[]
|
||||
categories?: string[]
|
||||
|
||||
}
|
||||
|
||||
export default function AdditionalTooltips({
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -12,7 +12,7 @@ export type ITooltips =
|
|||
// Crime data views
|
||||
| "incidents"
|
||||
| "heatmap"
|
||||
| "trends"
|
||||
| "units"
|
||||
| "patrol"
|
||||
| "reports"
|
||||
| "clusters"
|
||||
|
|
|
@ -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,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 (
|
||||
<Card className="w-full p-0 border-none shadow-none h-96">
|
||||
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
// };
|
||||
// }
|
Loading…
Reference in New Issue