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:
vergiLgood1 2025-05-05 04:57:32 +07:00
parent c282d958a5
commit 03a5e527d4
16 changed files with 1285 additions and 1730 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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