Refactor map layers and components for improved structure and functionality
- Removed the MapLayerManager component and integrated its functionality into the Layers component. - Added FlyToHandler component to manage map fly-to animations and highlight incidents. - Introduced ClusterLayer and UnclusteredPointLayer components for better handling of crime incident clustering and display. - Updated types in map.ts to include new interfaces for district features and layer props. - Enhanced crime data processing functions for better data management and visualization. - Adjusted button styles in button.tsx for improved UI consistency.
This commit is contained in:
parent
c282d958a5
commit
03a5e527d4
|
@ -59,7 +59,7 @@ export default function AdditionalTooltips({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={activeControl === control.id ? "default" : "ghost"}
|
variant={activeControl === control.id ? "default" : "ghost"}
|
||||||
size="icon"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||||
|
|
|
@ -2,17 +2,18 @@
|
||||||
|
|
||||||
import { Button } from "@/app/_components/ui/button"
|
import { Button } from "@/app/_components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
||||||
import { AlertTriangle, BarChart2, Clock, Map, Shield, Users } from "lucide-react"
|
import { AlertTriangle, BarChart2, Car, ChartScatter, Clock, Map, Shield, Users } from "lucide-react"
|
||||||
import { ITooltips } from "./tooltips"
|
import { ITooltips } from "./tooltips"
|
||||||
|
import { IconBubble, IconChartBubble } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
|
||||||
// Define the primary crime data controls
|
// Define the primary crime data controls
|
||||||
const crimeTooltips = [
|
const crimeTooltips = [
|
||||||
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
||||||
{ id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" },
|
{ id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" },
|
||||||
{ id: "trends" as ITooltips, icon: <BarChart2 size={20} />, label: "Crime Trends" },
|
{ id: "units" as ITooltips, icon: <BarChart2 size={20} />, label: "Units" },
|
||||||
{ id: "patrol" as ITooltips, icon: <Shield size={20} />, label: "Patrol Areas" },
|
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
|
||||||
{ id: "clusters" as ITooltips, icon: <Users size={20} />, label: "Clusters" },
|
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clusters" },
|
||||||
{ id: "timeline" as ITooltips, icon: <Clock size={20} />, label: "Time Analysis" },
|
{ id: "timeline" as ITooltips, icon: <Clock size={20} />, label: "Time Analysis" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -30,7 +31,7 @@ export default function CrimeTooltips({ activeControl, onControlChange }: CrimeT
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={activeControl === control.id ? "default" : "ghost"}
|
variant={activeControl === control.id ? "default" : "ghost"}
|
||||||
size="icon"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||||
|
|
|
@ -336,7 +336,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={showSearch ? "default" : "ghost"}
|
variant={showSearch ? "default" : "ghost"}
|
||||||
size="icon"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${showSearch
|
className={`h-8 w-8 rounded-md ${showSearch
|
||||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { ITooltips } from "./controls/top/tooltips"
|
||||||
import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
|
import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
|
||||||
import Tooltips from "./controls/top/tooltips"
|
import Tooltips from "./controls/top/tooltips"
|
||||||
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
|
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
|
||||||
|
import Layers from "./layers/layers"
|
||||||
|
|
||||||
// Updated CrimeIncident type to match the structure in crime_incidents
|
// Updated CrimeIncident type to match the structure in crime_incidents
|
||||||
interface ICrimeIncident {
|
interface ICrimeIncident {
|
||||||
|
@ -360,14 +361,21 @@ export default function CrimeMap() {
|
||||||
)}>
|
)}>
|
||||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
||||||
{/* District Layer with crime data - don't pass onClick if we want internal popup */}
|
{/* District Layer with crime data - don't pass onClick if we want internal popup */}
|
||||||
|
|
||||||
<DistrictLayer
|
<DistrictLayer
|
||||||
crimes={filteredCrimes || []}
|
crimes={filteredCrimes || []}
|
||||||
year={selectedYear.toString()}
|
year={selectedYear.toString()}
|
||||||
month={selectedMonth.toString()}
|
month={selectedMonth.toString()}
|
||||||
filterCategory={selectedCategory}
|
filterCategory={selectedCategory}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* <Layers
|
||||||
|
crimes={filteredCrimes || []}
|
||||||
|
year={selectedYear.toString()}
|
||||||
|
month={selectedMonth.toString()}
|
||||||
|
filterCategory={selectedCategory}
|
||||||
|
/> */}
|
||||||
|
|
||||||
{/* Popup for selected incident */}
|
{/* Popup for selected incident */}
|
||||||
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { IBaseLayerProps } from "@/app/_utils/types/map"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export default function FlyToHandler({ map }: Pick<IBaseLayerProps, "map">) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const handleFlyToEvent = (e: CustomEvent) => {
|
||||||
|
if (!map || !e.detail) return
|
||||||
|
|
||||||
|
const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail
|
||||||
|
|
||||||
|
map.flyTo({
|
||||||
|
center: [longitude, latitude],
|
||||||
|
zoom: zoom || 15,
|
||||||
|
bearing: bearing || 0,
|
||||||
|
pitch: pitch || 45,
|
||||||
|
duration: duration || 2000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a highlight or pulse effect to the target incident
|
||||||
|
if (map.getLayer("target-incident-highlight")) {
|
||||||
|
map.removeLayer("target-incident-highlight")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getSource("target-incident-highlight")) {
|
||||||
|
map.removeSource("target-incident-highlight")
|
||||||
|
}
|
||||||
|
|
||||||
|
map.addSource("target-incident-highlight", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "Feature",
|
||||||
|
geometry: {
|
||||||
|
type: "Point",
|
||||||
|
coordinates: [longitude, latitude],
|
||||||
|
},
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: "target-incident-highlight",
|
||||||
|
source: "target-incident-highlight",
|
||||||
|
type: "circle",
|
||||||
|
paint: {
|
||||||
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 10, 15, 15, 20, 20],
|
||||||
|
"circle-color": "#ff0000",
|
||||||
|
"circle-opacity": 0.7,
|
||||||
|
"circle-stroke-width": 2,
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a pulsing effect using animations
|
||||||
|
let size = 10
|
||||||
|
const animatePulse = () => {
|
||||||
|
if (!map || !map.getLayer("target-incident-highlight")) return
|
||||||
|
|
||||||
|
size = (size % 20) + 1
|
||||||
|
|
||||||
|
map.setPaintProperty("target-incident-highlight", "circle-radius", [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
10,
|
||||||
|
size,
|
||||||
|
15,
|
||||||
|
size * 1.5,
|
||||||
|
20,
|
||||||
|
size * 2,
|
||||||
|
])
|
||||||
|
|
||||||
|
requestAnimationFrame(animatePulse)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animatePulse)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (map && map.getCanvas()) {
|
||||||
|
map.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
|
@ -1,31 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react"
|
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
|
||||||
|
|
||||||
export interface BaseLayerProps {
|
|
||||||
visible?: boolean
|
|
||||||
beforeId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BaseLayer({ visible = true, beforeId }: BaseLayerProps) {
|
|
||||||
const { current: map } = useMap()
|
|
||||||
const layersAdded = useRef(false)
|
|
||||||
|
|
||||||
// Find the first symbol layer in the map style to insert layers before it
|
|
||||||
const getBeforeLayerId = (): string | undefined => {
|
|
||||||
if (!map || !beforeId) return undefined
|
|
||||||
|
|
||||||
if (beforeId) return beforeId
|
|
||||||
|
|
||||||
const layers = map.getStyle().layers
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.type === "symbol") {
|
|
||||||
return layer.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return { map, layersAdded, getBeforeLayerId }
|
|
||||||
}
|
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from "react"
|
||||||
|
|
||||||
|
import type mapboxgl from "mapbox-gl"
|
||||||
|
import type { GeoJSON } from "geojson"
|
||||||
|
import { IClusterLayerProps } from "@/app/_utils/types/map"
|
||||||
|
import { extractCrimeIncidents } from "@/app/_utils/map"
|
||||||
|
|
||||||
|
export default function ClusterLayer({
|
||||||
|
visible = true,
|
||||||
|
map,
|
||||||
|
crimes = [],
|
||||||
|
filterCategory = "all",
|
||||||
|
focusedDistrictId,
|
||||||
|
}: IClusterLayerProps) {
|
||||||
|
const handleClusterClick = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
e.originalEvent.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
||||||
|
|
||||||
|
if (!features || features.length === 0) return
|
||||||
|
|
||||||
|
const clusterId: number = features[0].properties?.cluster_id as number
|
||||||
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => {
|
||||||
|
if (err) return
|
||||||
|
|
||||||
|
map.easeTo({
|
||||||
|
center: (features[0].geometry as any).coordinates,
|
||||||
|
zoom: zoom ?? undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[map],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !visible) return
|
||||||
|
|
||||||
|
const onStyleLoad = () => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const layers = map.getStyle().layers
|
||||||
|
let firstSymbolId: string | undefined
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "symbol") {
|
||||||
|
firstSymbolId = layer.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getSource("crime-incidents")) {
|
||||||
|
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
|
||||||
|
|
||||||
|
map.addSource("crime-incidents", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: allIncidents as GeoJSON.Feature[],
|
||||||
|
},
|
||||||
|
cluster: true,
|
||||||
|
clusterMaxZoom: 14,
|
||||||
|
clusterRadius: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!map.getLayer("clusters")) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "clusters",
|
||||||
|
type: "circle",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
paint: {
|
||||||
|
"circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"],
|
||||||
|
"circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40],
|
||||||
|
"circle-opacity": 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility: focusedDistrictId ? "none" : "visible",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
firstSymbolId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getLayer("cluster-count")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "cluster-count",
|
||||||
|
type: "symbol",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
layout: {
|
||||||
|
"text-field": "{point_count_abbreviated}",
|
||||||
|
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
||||||
|
"text-size": 12,
|
||||||
|
visibility: focusedDistrictId ? "none" : "visible",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("mouseenter", "clusters", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer"
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseleave", "clusters", () => {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
map.off("click", "clusters", handleClusterClick)
|
||||||
|
map.on("click", "clusters", handleClusterClick)
|
||||||
|
} else {
|
||||||
|
// Update visibility based on focused district
|
||||||
|
if (map.getLayer("clusters")) {
|
||||||
|
map.setLayoutProperty("clusters", "visibility", focusedDistrictId ? "none" : "visible")
|
||||||
|
}
|
||||||
|
if (map.getLayer("cluster-count")) {
|
||||||
|
map.setLayoutProperty("cluster-count", "visibility", focusedDistrictId ? "none" : "visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding cluster layer:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
onStyleLoad()
|
||||||
|
} else {
|
||||||
|
map.once("style.load", onStyleLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (map) {
|
||||||
|
map.off("click", "clusters", handleClusterClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick])
|
||||||
|
|
||||||
|
// Update crime incidents data when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !map.getSource("crime-incidents")) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
|
||||||
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: allIncidents as GeoJSON.Feature[],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating incident data:", error)
|
||||||
|
}
|
||||||
|
}, [map, crimes, filterCategory])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
|
@ -1,337 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from "react"
|
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
|
||||||
import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
||||||
|
|
||||||
export interface CrimeClusterLayerProps {
|
|
||||||
visible?: boolean
|
|
||||||
crimes: ICrimes[]
|
|
||||||
filterCategory: string | "all"
|
|
||||||
isTimelapsePlaying?: boolean
|
|
||||||
beforeId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CrimeClusterLayer({
|
|
||||||
visible = true,
|
|
||||||
crimes = [],
|
|
||||||
filterCategory = "all",
|
|
||||||
isTimelapsePlaying = false,
|
|
||||||
beforeId,
|
|
||||||
}: CrimeClusterLayerProps) {
|
|
||||||
const { current: map } = useMap()
|
|
||||||
const layersAdded = useRef(false)
|
|
||||||
|
|
||||||
const handleClusterClick = useCallback(
|
|
||||||
(e: any) => {
|
|
||||||
if (!map) return
|
|
||||||
|
|
||||||
e.originalEvent.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
|
||||||
|
|
||||||
if (!features || features.length === 0) return
|
|
||||||
|
|
||||||
const clusterId: number = features[0].properties?.cluster_id as number
|
|
||||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => {
|
|
||||||
if (err) return
|
|
||||||
|
|
||||||
map.easeTo({
|
|
||||||
center: (features[0].geometry as any).coordinates,
|
|
||||||
zoom: zoom ?? undefined,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[map],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleIncidentClick = useCallback(
|
|
||||||
(e: any) => {
|
|
||||||
if (!map) return
|
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
|
|
||||||
if (!features || features.length === 0) return
|
|
||||||
|
|
||||||
const incident = features[0]
|
|
||||||
if (!incident.properties) return
|
|
||||||
|
|
||||||
e.originalEvent.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const incidentDetails = {
|
|
||||||
id: incident.properties.id,
|
|
||||||
district: incident.properties.district,
|
|
||||||
category: incident.properties.category,
|
|
||||||
type: incident.properties.incidentType,
|
|
||||||
description: incident.properties.description,
|
|
||||||
status: incident.properties?.status || "Unknown",
|
|
||||||
longitude: (incident.geometry as any).coordinates[0],
|
|
||||||
latitude: (incident.geometry as any).coordinates[1],
|
|
||||||
timestamp: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Incident clicked:", incidentDetails)
|
|
||||||
|
|
||||||
const customEvent = new CustomEvent("incident_click", {
|
|
||||||
detail: incidentDetails,
|
|
||||||
bubbles: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (map.getMap().getCanvas()) {
|
|
||||||
map.getMap().getCanvas().dispatchEvent(customEvent)
|
|
||||||
} else {
|
|
||||||
document.dispatchEvent(customEvent)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[map],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Initialize crime clusters and points
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map || !visible || crimes.length === 0) return
|
|
||||||
|
|
||||||
const onStyleLoad = () => {
|
|
||||||
if (!map) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the first symbol layer
|
|
||||||
let firstSymbolId = beforeId
|
|
||||||
if (!firstSymbolId) {
|
|
||||||
const layers = map.getStyle().layers
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.type === "symbol") {
|
|
||||||
firstSymbolId = layer.id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!map.getMap().getSource("crime-incidents")) {
|
|
||||||
const allIncidents = crimes.flatMap((crime) => {
|
|
||||||
let filteredIncidents = crime.crime_incidents
|
|
||||||
|
|
||||||
if (filterCategory !== "all") {
|
|
||||||
filteredIncidents = crime.crime_incidents.filter(
|
|
||||||
(incident) => incident.crime_categories.name === filterCategory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredIncidents.map((incident) => ({
|
|
||||||
type: "Feature" as const,
|
|
||||||
properties: {
|
|
||||||
id: incident.id,
|
|
||||||
district: crime.districts.name,
|
|
||||||
category: incident.crime_categories.name,
|
|
||||||
incidentType: incident.crime_categories.type,
|
|
||||||
level: crime.level,
|
|
||||||
description: incident.description,
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
type: "Point" as const,
|
|
||||||
coordinates: [incident.locations.longitude, incident.locations.latitude],
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
map.getMap().addSource("crime-incidents", {
|
|
||||||
type: "geojson",
|
|
||||||
data: {
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: allIncidents,
|
|
||||||
},
|
|
||||||
cluster: true,
|
|
||||||
clusterMaxZoom: 14,
|
|
||||||
clusterRadius: 50,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!map.getMap().getLayer("clusters")) {
|
|
||||||
map.getMap().addLayer(
|
|
||||||
{
|
|
||||||
id: "clusters",
|
|
||||||
type: "circle",
|
|
||||||
source: "crime-incidents",
|
|
||||||
filter: ["has", "point_count"],
|
|
||||||
paint: {
|
|
||||||
"circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"],
|
|
||||||
"circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40],
|
|
||||||
"circle-opacity": 0.75,
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
visibility: isTimelapsePlaying ? "none" : "visible",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
firstSymbolId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!map.getMap().getLayer("cluster-count")) {
|
|
||||||
map.getMap().addLayer({
|
|
||||||
id: "cluster-count",
|
|
||||||
type: "symbol",
|
|
||||||
source: "crime-incidents",
|
|
||||||
filter: ["has", "point_count"],
|
|
||||||
layout: {
|
|
||||||
"text-field": "{point_count_abbreviated}",
|
|
||||||
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
|
||||||
"text-size": 12,
|
|
||||||
"visibility": isTimelapsePlaying ? "none" : "visible",
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
"text-color": "#ffffff",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!map.getMap().getLayer("unclustered-point")) {
|
|
||||||
map.getMap().addLayer(
|
|
||||||
{
|
|
||||||
id: "unclustered-point",
|
|
||||||
type: "circle",
|
|
||||||
source: "crime-incidents",
|
|
||||||
filter: ["!", ["has", "point_count"]],
|
|
||||||
paint: {
|
|
||||||
"circle-color": "#11b4da",
|
|
||||||
"circle-radius": 8,
|
|
||||||
"circle-stroke-width": 1,
|
|
||||||
"circle-stroke-color": "#fff",
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
visibility: isTimelapsePlaying ? "none" : "visible",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
firstSymbolId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event handlers
|
|
||||||
map.on("mouseenter", "clusters", () => {
|
|
||||||
map.getCanvas().style.cursor = "pointer"
|
|
||||||
})
|
|
||||||
|
|
||||||
map.on("mouseleave", "clusters", () => {
|
|
||||||
map.getCanvas().style.cursor = ""
|
|
||||||
})
|
|
||||||
|
|
||||||
map.on("mouseenter", "unclustered-point", () => {
|
|
||||||
map.getCanvas().style.cursor = "pointer"
|
|
||||||
})
|
|
||||||
|
|
||||||
map.on("mouseleave", "unclustered-point", () => {
|
|
||||||
map.getCanvas().style.cursor = ""
|
|
||||||
})
|
|
||||||
|
|
||||||
// Attach click handlers
|
|
||||||
map.off("click", "clusters", handleClusterClick)
|
|
||||||
map.off("click", "unclustered-point", handleIncidentClick)
|
|
||||||
|
|
||||||
map.on("click", "clusters", handleClusterClick)
|
|
||||||
map.on("click", "unclustered-point", handleIncidentClick)
|
|
||||||
|
|
||||||
layersAdded.current = true
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error adding crime cluster layers:", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (map.isStyleLoaded()) {
|
|
||||||
onStyleLoad()
|
|
||||||
} else {
|
|
||||||
map.once("style.load", onStyleLoad)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (map) {
|
|
||||||
map.off("click", "clusters", handleClusterClick)
|
|
||||||
map.off("click", "unclustered-point", handleIncidentClick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [map, visible, crimes, filterCategory, handleClusterClick, handleIncidentClick, beforeId, isTimelapsePlaying])
|
|
||||||
|
|
||||||
// Update crime data when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map || !map.getMap().getSource("crime-incidents")) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
// If timeline is playing, hide all point/cluster layers to improve performance
|
|
||||||
if (isTimelapsePlaying) {
|
|
||||||
// Hide all incident points during timelapse
|
|
||||||
if (map.getMap().getLayer("clusters")) {
|
|
||||||
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
|
||||||
}
|
|
||||||
if (map.getMap().getLayer("unclustered-point")) {
|
|
||||||
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
|
|
||||||
}
|
|
||||||
if (map.getMap().getLayer("cluster-count")) {
|
|
||||||
map.getMap().setLayoutProperty("cluster-count", "visibility", "none")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the source with empty data to free up resources
|
|
||||||
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: [],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// When not playing, show all layers again
|
|
||||||
if (map.getMap().getLayer("clusters")) {
|
|
||||||
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
|
||||||
}
|
|
||||||
if (map.getMap().getLayer("unclustered-point")) {
|
|
||||||
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
|
||||||
}
|
|
||||||
if (map.getMap().getLayer("cluster-count")) {
|
|
||||||
map.getMap().setLayoutProperty("cluster-count", "visibility", "visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore detailed incidents when timelapse stops
|
|
||||||
const allIncidents = crimes.flatMap((crime) => {
|
|
||||||
if (!crime.crime_incidents) return []
|
|
||||||
|
|
||||||
let filteredIncidents = crime.crime_incidents
|
|
||||||
|
|
||||||
if (filterCategory !== "all") {
|
|
||||||
filteredIncidents = crime.crime_incidents.filter(
|
|
||||||
(incident) => incident.crime_categories && incident.crime_categories.name === filterCategory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredIncidents
|
|
||||||
.map((incident) => {
|
|
||||||
if (!incident.locations) {
|
|
||||||
console.warn("Missing location for incident:", incident.id)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "Feature" as const,
|
|
||||||
properties: {
|
|
||||||
id: incident.id,
|
|
||||||
district: crime.districts?.name || "Unknown",
|
|
||||||
category: incident.crime_categories?.name || "Unknown",
|
|
||||||
incidentType: incident.crime_categories?.type || "Unknown",
|
|
||||||
level: crime.level || "low",
|
|
||||||
description: incident.description || "",
|
|
||||||
},
|
|
||||||
geometry: {
|
|
||||||
type: "Point" as const,
|
|
||||||
coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update the source with detailed data
|
|
||||||
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
|
||||||
type: "FeatureCollection",
|
|
||||||
features: allIncidents as GeoJSON.Feature[],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating incident data:", error)
|
|
||||||
}
|
|
||||||
}, [map, crimes, filterCategory, isTimelapsePlaying])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
|
@ -1,28 +1,21 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { getCrimeRateColor } from "@/app/_utils/map"
|
||||||
|
import { IExtrusionLayerProps } from "@/app/_utils/types/map"
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
|
||||||
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
|
||||||
import { $Enums } from "@prisma/client"
|
|
||||||
|
|
||||||
export interface DistrictExtrusionLayerProps {
|
|
||||||
visible?: boolean
|
|
||||||
focusedDistrictId: string | null
|
|
||||||
crimeDataByDistrict: Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>
|
|
||||||
tilesetId: string
|
|
||||||
beforeId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DistrictExtrusionLayer({
|
export default function DistrictExtrusionLayer({
|
||||||
visible = true,
|
visible = true,
|
||||||
|
map,
|
||||||
|
tilesetId,
|
||||||
focusedDistrictId,
|
focusedDistrictId,
|
||||||
crimeDataByDistrict,
|
crimeDataByDistrict,
|
||||||
tilesetId,
|
}: IExtrusionLayerProps) {
|
||||||
beforeId,
|
const animationRef = useRef<number | null>(null)
|
||||||
}: DistrictExtrusionLayerProps) {
|
const bearingRef = useRef(0)
|
||||||
const { current: map } = useMap()
|
const rotationAnimationRef = useRef<number | null>(null)
|
||||||
const layersAdded = useRef(false)
|
|
||||||
|
|
||||||
|
// Handle extrusion layer creation and updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !visible) return
|
if (!map || !visible) return
|
||||||
|
|
||||||
|
@ -30,28 +23,17 @@ export default function DistrictExtrusionLayer({
|
||||||
if (!map) return
|
if (!map) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Make sure the districts source exists
|
const layers = map.getStyle().layers
|
||||||
if (!map.getMap().getSource("districts")) {
|
let firstSymbolId: string | undefined
|
||||||
map.getMap().addSource("districts", {
|
for (const layer of layers) {
|
||||||
type: "vector",
|
if (layer.type === "symbol") {
|
||||||
url: `mapbox://${tilesetId}`,
|
firstSymbolId = layer.id
|
||||||
})
|
break
|
||||||
}
|
|
||||||
|
|
||||||
// Get the first symbol layer
|
|
||||||
let firstSymbolId = beforeId
|
|
||||||
if (!firstSymbolId) {
|
|
||||||
const layers = map.getStyle().layers
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.type === "symbol") {
|
|
||||||
firstSymbolId = layer.id
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getMap().getLayer("district-extrusion")) {
|
if (!map.getLayer("district-extrusion")) {
|
||||||
map.getMap().addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
id: "district-extrusion",
|
id: "district-extrusion",
|
||||||
type: "fill-extrusion",
|
type: "fill-extrusion",
|
||||||
|
@ -65,13 +47,7 @@ export default function DistrictExtrusionLayer({
|
||||||
"match",
|
"match",
|
||||||
["get", "kode_kec"],
|
["get", "kode_kec"],
|
||||||
focusedDistrictId || "",
|
focusedDistrictId || "",
|
||||||
crimeDataByDistrict[focusedDistrictId || ""]?.level === "low"
|
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
|
||||||
? CRIME_RATE_COLORS.low
|
|
||||||
: crimeDataByDistrict[focusedDistrictId || ""]?.level === "medium"
|
|
||||||
? CRIME_RATE_COLORS.medium
|
|
||||||
: crimeDataByDistrict[focusedDistrictId || ""]?.level === "high"
|
|
||||||
? CRIME_RATE_COLORS.high
|
|
||||||
: CRIME_RATE_COLORS.default,
|
|
||||||
"transparent",
|
"transparent",
|
||||||
],
|
],
|
||||||
"transparent",
|
"transparent",
|
||||||
|
@ -89,10 +65,25 @@ export default function DistrictExtrusionLayer({
|
||||||
},
|
},
|
||||||
firstSymbolId,
|
firstSymbolId,
|
||||||
)
|
)
|
||||||
layersAdded.current = true
|
} else {
|
||||||
|
// Update existing layer
|
||||||
|
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""])
|
||||||
|
|
||||||
|
map.setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
||||||
|
"case",
|
||||||
|
["has", "kode_kec"],
|
||||||
|
[
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
focusedDistrictId || "",
|
||||||
|
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
|
||||||
|
"transparent",
|
||||||
|
],
|
||||||
|
"transparent",
|
||||||
|
])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding extrusion layer:", error)
|
console.error("Error adding district extrusion layer:", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,94 +92,135 @@ export default function DistrictExtrusionLayer({
|
||||||
} else {
|
} else {
|
||||||
map.once("style.load", onStyleLoad)
|
map.once("style.load", onStyleLoad)
|
||||||
}
|
}
|
||||||
}, [map, visible, tilesetId, beforeId])
|
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [map, visible, tilesetId, focusedDistrictId, crimeDataByDistrict])
|
||||||
|
|
||||||
|
// Handle extrusion height animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !layersAdded.current) return
|
if (!map || !map.getLayer("district-extrusion")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (map.getMap().getLayer("district-extrusion")) {
|
if (focusedDistrictId) {
|
||||||
map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""])
|
const startHeight = 0
|
||||||
|
const targetHeight = 800
|
||||||
|
const duration = 700
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
const animateHeight = (currentTime: number) => {
|
||||||
"case",
|
const elapsed = currentTime - startTime
|
||||||
["has", "kode_kec"],
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
[
|
const easedProgress = progress * (2 - progress)
|
||||||
"match",
|
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
||||||
["get", "kode_kec"],
|
|
||||||
focusedDistrictId || "",
|
|
||||||
crimeDataByDistrict[focusedDistrictId || ""]?.level === "low"
|
|
||||||
? CRIME_RATE_COLORS.low
|
|
||||||
: crimeDataByDistrict[focusedDistrictId || ""]?.level === "medium"
|
|
||||||
? CRIME_RATE_COLORS.medium
|
|
||||||
: crimeDataByDistrict[focusedDistrictId || ""]?.level === "high"
|
|
||||||
? CRIME_RATE_COLORS.high
|
|
||||||
: CRIME_RATE_COLORS.default,
|
|
||||||
"transparent",
|
|
||||||
],
|
|
||||||
"transparent",
|
|
||||||
])
|
|
||||||
|
|
||||||
if (focusedDistrictId) {
|
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
||||||
const startHeight = 0
|
"case",
|
||||||
const targetHeight = 800
|
["has", "kode_kec"],
|
||||||
const duration = 700
|
["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0],
|
||||||
const startTime = performance.now()
|
0,
|
||||||
|
])
|
||||||
|
|
||||||
const animateHeight = (currentTime: number) => {
|
if (progress < 1) {
|
||||||
const elapsed = currentTime - startTime
|
animationRef.current = requestAnimationFrame(animateHeight)
|
||||||
const progress = Math.min(elapsed / duration, 1)
|
} else {
|
||||||
const easedProgress = progress * (2 - progress)
|
// Start rotation after extrusion completes
|
||||||
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
startRotation()
|
||||||
|
|
||||||
map
|
|
||||||
.getMap()
|
|
||||||
.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
|
||||||
"case",
|
|
||||||
["has", "kode_kec"],
|
|
||||||
["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0],
|
|
||||||
0,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animateHeight)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(animateHeight)
|
|
||||||
} else {
|
|
||||||
const startHeight = 800
|
|
||||||
const targetHeight = 0
|
|
||||||
const duration = 500
|
|
||||||
const startTime = performance.now()
|
|
||||||
|
|
||||||
const animateHeightDown = (currentTime: number) => {
|
|
||||||
const elapsed = currentTime - startTime
|
|
||||||
const progress = Math.min(elapsed / duration, 1)
|
|
||||||
const easedProgress = progress * (2 - progress)
|
|
||||||
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
|
||||||
|
|
||||||
map
|
|
||||||
.getMap()
|
|
||||||
.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
|
||||||
"case",
|
|
||||||
["has", "kode_kec"],
|
|
||||||
["match", ["get", "kode_kec"], "", currentHeight, 0],
|
|
||||||
0,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(animateHeightDown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(animateHeightDown)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
}
|
||||||
|
animationRef.current = requestAnimationFrame(animateHeight)
|
||||||
|
} else {
|
||||||
|
const startHeight = 800
|
||||||
|
const targetHeight = 0
|
||||||
|
const duration = 500
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
const animateHeightDown = (currentTime: number) => {
|
||||||
|
const elapsed = currentTime - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const easedProgress = progress * (2 - progress)
|
||||||
|
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
||||||
|
|
||||||
|
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
||||||
|
"case",
|
||||||
|
["has", "kode_kec"],
|
||||||
|
["match", ["get", "kode_kec"], "", currentHeight, 0],
|
||||||
|
0,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
animationRef.current = requestAnimationFrame(animateHeightDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
}
|
||||||
|
animationRef.current = requestAnimationFrame(animateHeightDown)
|
||||||
|
|
||||||
|
// Stop rotation when unfocusing
|
||||||
|
if (rotationAnimationRef.current) {
|
||||||
|
cancelAnimationFrame(rotationAnimationRef.current)
|
||||||
|
rotationAnimationRef.current = null
|
||||||
|
}
|
||||||
|
bearingRef.current = 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating extrusion layer:", error)
|
console.error("Error animating district extrusion:", error)
|
||||||
}
|
}
|
||||||
}, [map, focusedDistrictId, crimeDataByDistrict])
|
}, [map, focusedDistrictId])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current)
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
if (rotationAnimationRef.current) {
|
||||||
|
cancelAnimationFrame(rotationAnimationRef.current)
|
||||||
|
rotationAnimationRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Start rotation animation
|
||||||
|
const startRotation = () => {
|
||||||
|
if (!map || !focusedDistrictId) return
|
||||||
|
|
||||||
|
const rotationSpeed = 0.05 // degrees per frame
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
if (!map || !focusedDistrictId) {
|
||||||
|
if (rotationAnimationRef.current) {
|
||||||
|
cancelAnimationFrame(rotationAnimationRef.current)
|
||||||
|
rotationAnimationRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update bearing with smooth increment
|
||||||
|
bearingRef.current = (bearingRef.current + rotationSpeed) % 360
|
||||||
|
map.setBearing(bearingRef.current)
|
||||||
|
|
||||||
|
// Continue the animation
|
||||||
|
rotationAnimationRef.current = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the animation loop
|
||||||
|
if (rotationAnimationRef.current) {
|
||||||
|
cancelAnimationFrame(rotationAnimationRef.current)
|
||||||
|
}
|
||||||
|
rotationAnimationRef.current = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,231 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } 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"
|
||||||
|
import DistrictExtrusionLayer from "./district-extrusion-layer"
|
||||||
|
import ClusterLayer from "./cluster-layer"
|
||||||
|
|
||||||
|
|
||||||
|
import type { ICrimes } from "@/app/_utils/types/crimes"
|
||||||
|
import { IDistrictFeature } from "@/app/_utils/types/map"
|
||||||
|
import { processCrimeDataByDistrict } from "@/app/_utils/map"
|
||||||
|
import DistrictFillLineLayer from "./district-layer"
|
||||||
|
import UnclusteredPointLayer from "./uncluster-layer"
|
||||||
|
import FlyToHandler from "../fly-to"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
// District layer props
|
||||||
|
export interface IDistrictLayerProps {
|
||||||
|
visible?: boolean
|
||||||
|
onClick?: (feature: IDistrictFeature) => void
|
||||||
|
year: string
|
||||||
|
month: string
|
||||||
|
filterCategory: string | "all"
|
||||||
|
crimes: ICrimes[]
|
||||||
|
tilesetId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layers({
|
||||||
|
visible = true,
|
||||||
|
onClick,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
filterCategory = "all",
|
||||||
|
crimes = [],
|
||||||
|
tilesetId = MAPBOX_TILESET_ID,
|
||||||
|
}: IDistrictLayerProps) {
|
||||||
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
if (!map) {
|
||||||
|
toast.error("Map not found")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapboxMap = map.getMap()
|
||||||
|
|
||||||
|
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null)
|
||||||
|
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
||||||
|
const selectedDistrictRef = useRef<IDistrictFeature | null>(null)
|
||||||
|
|
||||||
|
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
|
||||||
|
|
||||||
|
// Handle district selection
|
||||||
|
const handleDistrictClick = (district: IDistrictFeature) => {
|
||||||
|
selectedDistrictRef.current = district
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
onClick(district)
|
||||||
|
} else {
|
||||||
|
setSelectedDistrict(district)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle popup close
|
||||||
|
const handleCloseDistrictPopup = () => {
|
||||||
|
console.log("Closing district popup")
|
||||||
|
selectedDistrictRef.current = null
|
||||||
|
setSelectedDistrict(null)
|
||||||
|
setFocusedDistrictId(null)
|
||||||
|
|
||||||
|
// Reset pitch and bearing
|
||||||
|
if (map) {
|
||||||
|
map.easeTo({
|
||||||
|
zoom: BASE_ZOOM,
|
||||||
|
pitch: BASE_PITCH,
|
||||||
|
bearing: BASE_BEARING,
|
||||||
|
duration: 1500,
|
||||||
|
easing: (t) => t * (2 - t), // easeOutQuad
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show all clusters again when closing popup
|
||||||
|
if (map.getLayer("clusters")) {
|
||||||
|
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
||||||
|
}
|
||||||
|
if (map.getLayer("unclustered-point")) {
|
||||||
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected district when year/month/filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDistrictRef.current) {
|
||||||
|
const districtId = selectedDistrictRef.current.id
|
||||||
|
const districtCrime = crimes.find((crime) => crime.district_id === districtId)
|
||||||
|
|
||||||
|
if (districtCrime) {
|
||||||
|
const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear()
|
||||||
|
|
||||||
|
let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum)
|
||||||
|
|
||||||
|
if (!demographics && districtCrime.districts.demographics?.length) {
|
||||||
|
demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
let geographics = districtCrime.districts.geographics?.find((g) => g.year === selectedYearNum)
|
||||||
|
|
||||||
|
if (!geographics && districtCrime.districts.geographics?.length) {
|
||||||
|
const validGeographics = districtCrime.districts.geographics
|
||||||
|
.filter((g) => g.year !== null)
|
||||||
|
.sort((a, b) => (b.year || 0) - (a.year || 0))
|
||||||
|
|
||||||
|
geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!demographics || !geographics) {
|
||||||
|
console.error("Missing district data:", { demographics, geographics })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const crime_incidents = districtCrime.crime_incidents
|
||||||
|
.filter((incident) => filterCategory === "all" || incident.crime_categories.name === filterCategory)
|
||||||
|
.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
timestamp: incident.timestamp,
|
||||||
|
description: incident.description,
|
||||||
|
status: incident.status || "",
|
||||||
|
category: incident.crime_categories.name,
|
||||||
|
type: incident.crime_categories.type || "",
|
||||||
|
address: incident.locations.address || "",
|
||||||
|
latitude: incident.locations.latitude,
|
||||||
|
longitude: incident.locations.longitude,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const updatedDistrict: IDistrictFeature = {
|
||||||
|
...selectedDistrictRef.current,
|
||||||
|
number_of_crime: crimeDataByDistrict[districtId]?.number_of_crime || 0,
|
||||||
|
level: crimeDataByDistrict[districtId]?.level || selectedDistrictRef.current.level,
|
||||||
|
demographics: {
|
||||||
|
number_of_unemployed: demographics.number_of_unemployed,
|
||||||
|
population: demographics.population,
|
||||||
|
population_density: demographics.population_density,
|
||||||
|
year: demographics.year,
|
||||||
|
},
|
||||||
|
geographics: {
|
||||||
|
address: geographics.address || "",
|
||||||
|
land_area: geographics.land_area || 0,
|
||||||
|
year: geographics.year || 0,
|
||||||
|
latitude: geographics.latitude,
|
||||||
|
longitude: geographics.longitude,
|
||||||
|
},
|
||||||
|
crime_incidents,
|
||||||
|
selectedYear: year,
|
||||||
|
selectedMonth: month,
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDistrictRef.current = updatedDistrict
|
||||||
|
|
||||||
|
setSelectedDistrict((prevDistrict) => {
|
||||||
|
if (
|
||||||
|
prevDistrict?.id === updatedDistrict.id &&
|
||||||
|
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
|
||||||
|
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
|
||||||
|
) {
|
||||||
|
return prevDistrict
|
||||||
|
}
|
||||||
|
return updatedDistrict
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DistrictFillLineLayer
|
||||||
|
visible={visible}
|
||||||
|
map={mapboxMap}
|
||||||
|
onClick={handleDistrictClick}
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
crimes={crimes}
|
||||||
|
tilesetId={tilesetId}
|
||||||
|
focusedDistrictId={focusedDistrictId}
|
||||||
|
setFocusedDistrictId={setFocusedDistrictId}
|
||||||
|
crimeDataByDistrict={crimeDataByDistrict}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DistrictExtrusionLayer
|
||||||
|
visible={visible}
|
||||||
|
map={mapboxMap}
|
||||||
|
tilesetId={tilesetId}
|
||||||
|
focusedDistrictId={focusedDistrictId}
|
||||||
|
crimeDataByDistrict={crimeDataByDistrict}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClusterLayer
|
||||||
|
visible={visible}
|
||||||
|
map={mapboxMap}
|
||||||
|
crimes={crimes}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
focusedDistrictId={focusedDistrictId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UnclusteredPointLayer
|
||||||
|
visible={visible}
|
||||||
|
map={mapboxMap}
|
||||||
|
crimes={crimes}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
focusedDistrictId={focusedDistrictId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FlyToHandler map={mapboxMap} />
|
||||||
|
|
||||||
|
{selectedDistrict && (
|
||||||
|
<DistrictPopup
|
||||||
|
longitude={selectedDistrict.longitude || 0}
|
||||||
|
latitude={selectedDistrict.latitude || 0}
|
||||||
|
onClose={handleCloseDistrictPopup}
|
||||||
|
district={selectedDistrict}
|
||||||
|
year={year}
|
||||||
|
month={month}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,226 +0,0 @@
|
||||||
// "use client"
|
|
||||||
|
|
||||||
// import { useState, useEffect, useRef } from "react"
|
|
||||||
// import { useMap } from "react-map-gl/mapbox"
|
|
||||||
// import { MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
|
|
||||||
// import { $Enums } from "@prisma/client"
|
|
||||||
|
|
||||||
// import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
||||||
// import DistrictLayer, { DistrictFeature } from "./district-layer"
|
|
||||||
// import DistrictExtrusionLayer from "./district-extrusion-layer"
|
|
||||||
// import CrimeClusterLayer from "./crime-cluster-layer"
|
|
||||||
|
|
||||||
// export interface MapLayerManagerProps {
|
|
||||||
// visible?: boolean
|
|
||||||
// crimes: ICrimes[]
|
|
||||||
// year: string
|
|
||||||
// month: string
|
|
||||||
// filterCategory: string | "all"
|
|
||||||
// tilesetId?: string
|
|
||||||
// isTimelapsePlaying?: boolean
|
|
||||||
// onDistrictClick?: (feature: DistrictFeature) => void
|
|
||||||
// }
|
|
||||||
|
|
||||||
// export default function MapLayerManager({
|
|
||||||
// visible = true,
|
|
||||||
// crimes = [],
|
|
||||||
// year,
|
|
||||||
// month,
|
|
||||||
// filterCategory = "all",
|
|
||||||
// tilesetId = MAPBOX_TILESET_ID,
|
|
||||||
// isTimelapsePlaying = false,
|
|
||||||
// onDistrictClick,
|
|
||||||
// }: MapLayerManagerProps) {
|
|
||||||
// const { current: map } = useMap()
|
|
||||||
// const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
|
||||||
// const [isStyleLoaded, setIsStyleLoaded] = useState<boolean>(false)
|
|
||||||
// const [beforeId, setBeforeId] = useState<string | undefined>(undefined)
|
|
||||||
// const initAttempts = useRef(0)
|
|
||||||
|
|
||||||
// // Compute crime data by district for all layer components to use
|
|
||||||
// const crimeDataByDistrict = crimes.reduce(
|
|
||||||
// (acc, crime) => {
|
|
||||||
// const districtId = crime.district_id
|
|
||||||
// acc[districtId] = {
|
|
||||||
// number_of_crime: crime.number_of_crime,
|
|
||||||
// level: crime.level,
|
|
||||||
// }
|
|
||||||
// return acc
|
|
||||||
// },
|
|
||||||
// {} as Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>,
|
|
||||||
// )
|
|
||||||
|
|
||||||
// // Ensure map is ready after mounting - try multiple approaches
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (!map) {
|
|
||||||
// console.error("Map not available in MapLayerManager");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// console.log("MapLayerManager mounted, checking map status");
|
|
||||||
|
|
||||||
// // Direct initialization if already loaded
|
|
||||||
// if (map.getMap().isStyleLoaded()) {
|
|
||||||
// console.log("Map style already loaded - direct init");
|
|
||||||
// setIsStyleLoaded(true);
|
|
||||||
// try {
|
|
||||||
// const layers = map.getMap().getStyle().layers;
|
|
||||||
// for (const layer of layers) {
|
|
||||||
// if (layer.type === "symbol") {
|
|
||||||
// setBeforeId(layer.id);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// console.warn("Error finding symbol layer:", err);
|
|
||||||
// }
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // Listen for style load event
|
|
||||||
// const onStyleLoad = () => {
|
|
||||||
// console.log("Map style.load event fired");
|
|
||||||
// setIsStyleLoaded(true);
|
|
||||||
// try {
|
|
||||||
// const layers = map.getMap().getStyle().layers;
|
|
||||||
// for (const layer of layers) {
|
|
||||||
// if (layer.type === "symbol") {
|
|
||||||
// setBeforeId(layer.id);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// console.warn("Error finding symbol layer:", err);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Add event listener
|
|
||||||
// map.getMap().once('style.load', onStyleLoad);
|
|
||||||
|
|
||||||
// // Multiple retry attempts with increasing delays
|
|
||||||
// const checkStyleLoaded = () => {
|
|
||||||
// initAttempts.current += 1;
|
|
||||||
|
|
||||||
// if (initAttempts.current > 10) {
|
|
||||||
// console.error("Failed to detect loaded map style after 10 attempts");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (map.getMap().isStyleLoaded()) {
|
|
||||||
// console.log(`Map style loaded (detected on attempt ${initAttempts.current})`);
|
|
||||||
// map.getMap().off('style.load', onStyleLoad);
|
|
||||||
// setIsStyleLoaded(true);
|
|
||||||
// try {
|
|
||||||
// const layers = map.getMap().getStyle().layers;
|
|
||||||
// for (const layer of layers) {
|
|
||||||
// if (layer.type === "symbol") {
|
|
||||||
// setBeforeId(layer.id);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// console.warn("Error finding symbol layer:", err);
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// console.log(`Waiting for map style to load... (attempt ${initAttempts.current})`);
|
|
||||||
// setTimeout(checkStyleLoaded, 200 * initAttempts.current); // Increasing delay
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
// // Start checking after a short delay
|
|
||||||
// setTimeout(checkStyleLoaded, 100);
|
|
||||||
|
|
||||||
// // Cleanup
|
|
||||||
// return () => {
|
|
||||||
// map.getMap().off('style.load', onStyleLoad);
|
|
||||||
// };
|
|
||||||
// }, [map]);
|
|
||||||
|
|
||||||
// // Force a re-check when map or visibility changes
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (!map || !visible) return;
|
|
||||||
|
|
||||||
// if (!isStyleLoaded && map.getMap().isStyleLoaded()) {
|
|
||||||
// console.log("Map style detected as loaded after prop change");
|
|
||||||
// setIsStyleLoaded(true);
|
|
||||||
// try {
|
|
||||||
// const layers = map.getMap().getStyle().layers;
|
|
||||||
// for (const layer of layers) {
|
|
||||||
// if (layer.type === "symbol") {
|
|
||||||
// setBeforeId(layer.id);
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// console.warn("Error finding symbol layer:", err);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }, [map, visible, isStyleLoaded]);
|
|
||||||
|
|
||||||
// // Print debug info
|
|
||||||
// useEffect(() => {
|
|
||||||
// console.log("MapLayerManager state:", {
|
|
||||||
// mapAvailable: !!map,
|
|
||||||
// isStyleLoaded,
|
|
||||||
// beforeId,
|
|
||||||
// crimeCount: crimes.length,
|
|
||||||
// visible
|
|
||||||
// });
|
|
||||||
// }, [map, isStyleLoaded, beforeId, crimes, visible]);
|
|
||||||
|
|
||||||
// // Debug: Force isStyleLoaded after a timeout as a last resort
|
|
||||||
// useEffect(() => {
|
|
||||||
// if (isStyleLoaded || !map) return;
|
|
||||||
|
|
||||||
// const forceTimeout = setTimeout(() => {
|
|
||||||
// if (!isStyleLoaded && map) {
|
|
||||||
// console.warn("Forcing isStyleLoaded=true after timeout");
|
|
||||||
// setIsStyleLoaded(true);
|
|
||||||
// }
|
|
||||||
// }, 2000);
|
|
||||||
|
|
||||||
// return () => clearTimeout(forceTimeout);
|
|
||||||
// }, [map, isStyleLoaded]);
|
|
||||||
|
|
||||||
// if (!visible || !map) {
|
|
||||||
// console.log("MapLayerManager not rendering: visible=", visible, "map=", !!map);
|
|
||||||
// return null;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return (
|
|
||||||
// <>
|
|
||||||
// {map && (isStyleLoaded || initAttempts.current > 5) && (
|
|
||||||
// <>
|
|
||||||
// <DistrictLayer
|
|
||||||
// visible={true}
|
|
||||||
// onClick={onDistrictClick}
|
|
||||||
// year={year}
|
|
||||||
// month={month}
|
|
||||||
// filterCategory={filterCategory}
|
|
||||||
// crimes={crimes}
|
|
||||||
// tilesetId={tilesetId}
|
|
||||||
// isTimelapsePlaying={isTimelapsePlaying}
|
|
||||||
// onDistrictFocus={setFocusedDistrictId}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <DistrictExtrusionLayer
|
|
||||||
// visible={true}
|
|
||||||
// focusedDistrictId={focusedDistrictId}
|
|
||||||
// crimeDataByDistrict={crimeDataByDistrict}
|
|
||||||
// tilesetId={tilesetId!}
|
|
||||||
// beforeId={beforeId}
|
|
||||||
// />
|
|
||||||
|
|
||||||
// <CrimeClusterLayer
|
|
||||||
// visible={!focusedDistrictId}
|
|
||||||
// crimes={crimes}
|
|
||||||
// filterCategory={filterCategory}
|
|
||||||
// isTimelapsePlaying={isTimelapsePlaying}
|
|
||||||
// beforeId={beforeId}
|
|
||||||
// />
|
|
||||||
// </>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// </>
|
|
||||||
// );
|
|
||||||
// }
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { IUnclusteredPointLayerProps } from "@/app/_utils/types/map"
|
||||||
|
import { useEffect, useCallback } from "react"
|
||||||
|
|
||||||
|
|
||||||
|
export default function UnclusteredPointLayer({
|
||||||
|
visible = true,
|
||||||
|
map,
|
||||||
|
crimes = [],
|
||||||
|
filterCategory = "all",
|
||||||
|
focusedDistrictId,
|
||||||
|
}: IUnclusteredPointLayerProps) {
|
||||||
|
const handleIncidentClick = useCallback(
|
||||||
|
(e: any) => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
|
||||||
|
if (!features || features.length === 0) return
|
||||||
|
|
||||||
|
const incident = features[0]
|
||||||
|
if (!incident.properties) return
|
||||||
|
|
||||||
|
e.originalEvent.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const incidentDetails = {
|
||||||
|
id: incident.properties.id,
|
||||||
|
district: incident.properties.district,
|
||||||
|
category: incident.properties.category,
|
||||||
|
type: incident.properties.incidentType,
|
||||||
|
description: incident.properties.description,
|
||||||
|
status: incident.properties?.status || "Unknown",
|
||||||
|
longitude: (incident.geometry as any).coordinates[0],
|
||||||
|
latitude: (incident.geometry as any).coordinates[1],
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Incident clicked:", incidentDetails)
|
||||||
|
|
||||||
|
const customEvent = new CustomEvent("incident_click", {
|
||||||
|
detail: incidentDetails,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (map.getCanvas()) {
|
||||||
|
map.getCanvas().dispatchEvent(customEvent)
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(customEvent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[map],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !visible) return
|
||||||
|
|
||||||
|
const onStyleLoad = () => {
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const layers = map.getStyle().layers
|
||||||
|
let firstSymbolId: string | undefined
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "symbol") {
|
||||||
|
firstSymbolId = layer.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getSource("crime-incidents") && !map.getLayer("unclustered-point")) {
|
||||||
|
map.addLayer(
|
||||||
|
{
|
||||||
|
id: "unclustered-point",
|
||||||
|
type: "circle",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#11b4da",
|
||||||
|
"circle-radius": 8,
|
||||||
|
"circle-stroke-width": 1,
|
||||||
|
"circle-stroke-color": "#fff",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
visibility: focusedDistrictId ? "none" : "visible",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
firstSymbolId,
|
||||||
|
)
|
||||||
|
|
||||||
|
map.on("mouseenter", "unclustered-point", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer"
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseleave", "unclustered-point", () => {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
map.off("click", "unclustered-point", handleIncidentClick)
|
||||||
|
map.on("click", "unclustered-point", handleIncidentClick)
|
||||||
|
} else if (map.getLayer("unclustered-point")) {
|
||||||
|
// Update visibility based on focused district
|
||||||
|
map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding unclustered point layer:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
onStyleLoad()
|
||||||
|
} else {
|
||||||
|
map.once("style.load", onStyleLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (map) {
|
||||||
|
map.off("click", "unclustered-point", handleIncidentClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [map, visible, focusedDistrictId, handleIncidentClick])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
|
@ -21,10 +21,11 @@ const buttonVariants = cva(
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
medium: "h-10 w-10 px-6",
|
||||||
|
icon: "h-10 w-10",
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: "h-9 rounded-md px-3",
|
||||||
xs: "h-7 rounded-md px-2",
|
xs: "h-7 rounded-md px-2",
|
||||||
lg: "h-11 rounded-md px-8",
|
|
||||||
icon: "h-10 w-10",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { $Enums } from '@prisma/client';
|
||||||
|
import { CRIME_RATE_COLORS } from '@/app/_utils/const/map';
|
||||||
|
import type { ICrimes } from '@/app/_utils/types/crimes';
|
||||||
|
import { IDistrictFeature } from './types/map';
|
||||||
|
|
||||||
|
// Process crime data by district
|
||||||
|
export const processCrimeDataByDistrict = (crimes: ICrimes[]) => {
|
||||||
|
return crimes.reduce(
|
||||||
|
(acc, crime) => {
|
||||||
|
const districtId = crime.district_id;
|
||||||
|
|
||||||
|
acc[districtId] = {
|
||||||
|
number_of_crime: crime.number_of_crime,
|
||||||
|
level: crime.level,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{ number_of_crime?: number; level?: $Enums.crime_rates }
|
||||||
|
>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get color for crime rate level
|
||||||
|
export const getCrimeRateColor = (level?: $Enums.crime_rates) => {
|
||||||
|
if (!level) return CRIME_RATE_COLORS.default;
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'low':
|
||||||
|
return CRIME_RATE_COLORS.low;
|
||||||
|
case 'medium':
|
||||||
|
return CRIME_RATE_COLORS.medium;
|
||||||
|
case 'high':
|
||||||
|
return CRIME_RATE_COLORS.high;
|
||||||
|
default:
|
||||||
|
return CRIME_RATE_COLORS.default;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create fill color expression for district layer
|
||||||
|
export const createFillColorExpression = (
|
||||||
|
focusedDistrictId: string | null,
|
||||||
|
crimeDataByDistrict: Record<
|
||||||
|
string,
|
||||||
|
{ number_of_crime?: number; level?: $Enums.crime_rates }
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const colorEntries = focusedDistrictId
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
focusedDistrictId,
|
||||||
|
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
|
||||||
|
],
|
||||||
|
'rgba(0,0,0,0.05)',
|
||||||
|
]
|
||||||
|
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
|
return [districtId, getCrimeRateColor(data.level)];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'case',
|
||||||
|
['has', 'kode_kec'],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['get', 'kode_kec'],
|
||||||
|
...colorEntries,
|
||||||
|
focusedDistrictId ? 'rgba(0,0,0,0.05)' : CRIME_RATE_COLORS.default,
|
||||||
|
],
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract crime incidents for GeoJSON
|
||||||
|
export const extractCrimeIncidents = (
|
||||||
|
crimes: ICrimes[],
|
||||||
|
filterCategory: string | 'all'
|
||||||
|
) => {
|
||||||
|
return crimes.flatMap((crime) => {
|
||||||
|
if (!crime.crime_incidents) return [];
|
||||||
|
|
||||||
|
let filteredIncidents = crime.crime_incidents;
|
||||||
|
|
||||||
|
if (filterCategory !== 'all') {
|
||||||
|
filteredIncidents = crime.crime_incidents.filter(
|
||||||
|
(incident) =>
|
||||||
|
incident.crime_categories &&
|
||||||
|
incident.crime_categories.name === filterCategory
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredIncidents
|
||||||
|
.map((incident) => {
|
||||||
|
if (!incident.locations) {
|
||||||
|
console.warn('Missing location for incident:', incident.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: incident.id,
|
||||||
|
district: crime.districts?.name || 'Unknown',
|
||||||
|
category: incident.crime_categories?.name || 'Unknown',
|
||||||
|
incidentType: incident.crime_categories?.type || 'Unknown',
|
||||||
|
level: crime.level || 'low',
|
||||||
|
description: incident.description || '',
|
||||||
|
status: incident.status || '',
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'Point' as const,
|
||||||
|
coordinates: [
|
||||||
|
incident.locations.longitude || 0,
|
||||||
|
incident.locations.latitude || 0,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process district feature from map click
|
||||||
|
export const processDistrictFeature = (
|
||||||
|
feature: any,
|
||||||
|
e: any,
|
||||||
|
districtId: string,
|
||||||
|
crimeDataByDistrict: Record<
|
||||||
|
string,
|
||||||
|
{ number_of_crime?: number; level?: $Enums.crime_rates }
|
||||||
|
>,
|
||||||
|
crimes: ICrimes[],
|
||||||
|
year: string,
|
||||||
|
month: string
|
||||||
|
): IDistrictFeature | null => {
|
||||||
|
const crimeData = crimeDataByDistrict[districtId] || {};
|
||||||
|
let crime_incidents: Array<{
|
||||||
|
id: string;
|
||||||
|
timestamp: Date;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const districtCrimes = crimes.filter(
|
||||||
|
(crime) => crime.district_id === districtId
|
||||||
|
);
|
||||||
|
|
||||||
|
districtCrimes.forEach((crimeRecord) => {
|
||||||
|
if (crimeRecord && crimeRecord.crime_incidents) {
|
||||||
|
const incidents = crimeRecord.crime_incidents.map((incident) => ({
|
||||||
|
id: incident.id,
|
||||||
|
timestamp: incident.timestamp,
|
||||||
|
description: incident.description || '',
|
||||||
|
status: incident.status || '',
|
||||||
|
category: incident.crime_categories?.name || '',
|
||||||
|
type: incident.crime_categories?.type || '',
|
||||||
|
address: incident.locations?.address || '',
|
||||||
|
latitude: incident.locations?.latitude || 0,
|
||||||
|
longitude: incident.locations?.longitude || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
crime_incidents = [...crime_incidents, ...incidents];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstDistrictCrime =
|
||||||
|
districtCrimes.length > 0 ? districtCrimes[0] : null;
|
||||||
|
if (!firstDistrictCrime) return null;
|
||||||
|
|
||||||
|
const selectedYearNum = year
|
||||||
|
? Number.parseInt(year)
|
||||||
|
: new Date().getFullYear();
|
||||||
|
|
||||||
|
let demographics = firstDistrictCrime?.districts.demographics?.find(
|
||||||
|
(d) => d.year === selectedYearNum
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!demographics && firstDistrictCrime?.districts.demographics?.length) {
|
||||||
|
demographics = firstDistrictCrime.districts.demographics.sort(
|
||||||
|
(a, b) => b.year - a.year
|
||||||
|
)[0];
|
||||||
|
console.log(
|
||||||
|
`Tidak ada data demografis untuk tahun ${selectedYearNum}, menggunakan data tahun ${demographics.year}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let geographics = firstDistrictCrime?.districts.geographics?.find(
|
||||||
|
(g) => g.year === selectedYearNum
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!geographics && firstDistrictCrime?.districts.geographics?.length) {
|
||||||
|
const validGeographics = firstDistrictCrime.districts.geographics
|
||||||
|
.filter((g) => g.year !== null)
|
||||||
|
.sort((a, b) => (b.year || 0) - (a.year || 0));
|
||||||
|
|
||||||
|
geographics =
|
||||||
|
validGeographics.length > 0
|
||||||
|
? validGeographics[0]
|
||||||
|
: firstDistrictCrime.districts.geographics[0];
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Tidak ada data geografis untuk tahun ${selectedYearNum}, menggunakan data ${geographics.year ? `tahun ${geographics.year}` : 'tanpa tahun'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickLng = e.lngLat ? e.lngLat.lng : null;
|
||||||
|
const clickLat = e.lngLat ? e.lngLat.lat : null;
|
||||||
|
|
||||||
|
if (!geographics) {
|
||||||
|
console.error('Missing geographics data for district:', districtId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!demographics) {
|
||||||
|
console.error('Missing demographics data for district:', districtId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: districtId,
|
||||||
|
name:
|
||||||
|
feature.properties.nama ||
|
||||||
|
feature.properties.kecamatan ||
|
||||||
|
'Unknown District',
|
||||||
|
longitude: geographics.longitude || clickLng || 0,
|
||||||
|
latitude: geographics.latitude || clickLat || 0,
|
||||||
|
number_of_crime: crimeData.number_of_crime || 0,
|
||||||
|
level: crimeData.level || $Enums.crime_rates.low,
|
||||||
|
demographics: {
|
||||||
|
number_of_unemployed: demographics.number_of_unemployed,
|
||||||
|
population: demographics.population,
|
||||||
|
population_density: demographics.population_density,
|
||||||
|
year: demographics.year,
|
||||||
|
},
|
||||||
|
geographics: {
|
||||||
|
address: geographics.address || '',
|
||||||
|
land_area: geographics.land_area || 0,
|
||||||
|
year: geographics.year || 0,
|
||||||
|
latitude: geographics.latitude,
|
||||||
|
longitude: geographics.longitude,
|
||||||
|
},
|
||||||
|
crime_incidents: crime_incidents || [],
|
||||||
|
selectedYear: year,
|
||||||
|
selectedMonth: month,
|
||||||
|
isFocused: true,
|
||||||
|
};
|
||||||
|
};
|
|
@ -14,4 +14,90 @@ export interface IGeoJSONFeature {
|
||||||
export interface IGeoJSONFeatureCollection {
|
export interface IGeoJSONFeatureCollection {
|
||||||
type: 'FeatureCollection';
|
type: 'FeatureCollection';
|
||||||
features: IGeoJSONFeature[];
|
features: IGeoJSONFeature[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { $Enums } from '@prisma/client';
|
||||||
|
import type { ICrimes } from '@/app/_utils/types/crimes';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
|
||||||
|
// Types for district properties
|
||||||
|
export interface IDistrictFeature {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
|
number_of_crime: number;
|
||||||
|
level: $Enums.crime_rates;
|
||||||
|
demographics: {
|
||||||
|
number_of_unemployed: number;
|
||||||
|
population: number;
|
||||||
|
population_density: number;
|
||||||
|
year: number;
|
||||||
|
};
|
||||||
|
geographics: {
|
||||||
|
address: string;
|
||||||
|
land_area: number;
|
||||||
|
year: number;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
crime_incidents: Array<{
|
||||||
|
id: string;
|
||||||
|
timestamp: Date;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}>;
|
||||||
|
selectedYear?: string;
|
||||||
|
selectedMonth?: string;
|
||||||
|
isFocused?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base props for all map layers
|
||||||
|
export interface IBaseLayerProps {
|
||||||
|
visible?: boolean;
|
||||||
|
map: mapboxgl.Map | null;
|
||||||
|
tilesetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// District layer props
|
||||||
|
export interface IDistrictLayerProps extends IBaseLayerProps {
|
||||||
|
onClick?: (feature: IDistrictFeature) => void;
|
||||||
|
year: string;
|
||||||
|
month: string;
|
||||||
|
filterCategory: string | 'all';
|
||||||
|
crimes: ICrimes[];
|
||||||
|
focusedDistrictId: string | null;
|
||||||
|
setFocusedDistrictId: (id: string | null) => void;
|
||||||
|
crimeDataByDistrict: Record<
|
||||||
|
string,
|
||||||
|
{ number_of_crime?: number; level?: $Enums.crime_rates }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrusion layer props
|
||||||
|
export interface IExtrusionLayerProps extends IBaseLayerProps {
|
||||||
|
focusedDistrictId: string | null;
|
||||||
|
crimeDataByDistrict: Record<
|
||||||
|
string,
|
||||||
|
{ number_of_crime?: number; level?: $Enums.crime_rates }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster layer props
|
||||||
|
export interface IClusterLayerProps extends IBaseLayerProps {
|
||||||
|
crimes: ICrimes[];
|
||||||
|
filterCategory: string | 'all';
|
||||||
|
focusedDistrictId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unclustered point layer props
|
||||||
|
export interface IUnclusteredPointLayerProps extends IBaseLayerProps {
|
||||||
|
crimes: ICrimes[];
|
||||||
|
filterCategory: string | 'all';
|
||||||
|
focusedDistrictId: string | null;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue