1125 lines
47 KiB
TypeScript
1125 lines
47 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useRef, useCallback } from "react"
|
|
import { useMap } from "react-map-gl/mapbox"
|
|
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM, CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
|
|
|
|
import { $Enums } from "@prisma/client"
|
|
import DistrictPopup from "../pop-up/district-popup"
|
|
import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
|
|
// Types for district properties
|
|
export interface DistrictFeature {
|
|
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 // Add a property to track if district is focused
|
|
}
|
|
|
|
// District layer props
|
|
export interface DistrictLayerProps {
|
|
visible?: boolean
|
|
onClick?: (feature: DistrictFeature) => void
|
|
year: string
|
|
month: string
|
|
filterCategory: string | "all"
|
|
crimes: ICrimes[]
|
|
tilesetId?: string
|
|
}
|
|
|
|
export default function DistrictLayer({
|
|
visible = true,
|
|
onClick,
|
|
year,
|
|
month,
|
|
filterCategory = "all",
|
|
crimes = [],
|
|
tilesetId = MAPBOX_TILESET_ID,
|
|
}: DistrictLayerProps) {
|
|
const { current: map } = useMap()
|
|
|
|
const [hoverInfo, setHoverInfo] = useState<{
|
|
x: number
|
|
y: number
|
|
feature: any
|
|
} | null>(null)
|
|
|
|
const selectedDistrictRef = useRef<DistrictFeature | null>(null)
|
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
|
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
|
const persistentFocusedDistrictRef = useRef<string | null>(null)
|
|
const rotationAnimationRef = useRef<number | null>(null)
|
|
const bearingRef = useRef(0)
|
|
const layersAdded = useRef(false)
|
|
const isFocusedMode = useRef(false)
|
|
|
|
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 }>,
|
|
)
|
|
|
|
const handleDistrictClick = (e: any) => {
|
|
const incidentFeatures = map?.queryRenderedFeatures(e.point, { layers: ["unclustered-point", "clusters"] })
|
|
|
|
if (incidentFeatures && incidentFeatures.length > 0) {
|
|
return
|
|
}
|
|
|
|
if (!map || !e.features || e.features.length === 0) return
|
|
|
|
const feature = e.features[0]
|
|
const districtId = feature.properties.kode_kec
|
|
|
|
// If clicking the same district, deselect it
|
|
if (focusedDistrictId === districtId) {
|
|
setFocusedDistrictId(null)
|
|
persistentFocusedDistrictRef.current = null
|
|
isFocusedMode.current = false
|
|
selectedDistrictRef.current = null
|
|
setSelectedDistrict(null)
|
|
|
|
// Reset animation and map view when deselecting
|
|
if (rotationAnimationRef.current) {
|
|
cancelAnimationFrame(rotationAnimationRef.current)
|
|
rotationAnimationRef.current = null
|
|
}
|
|
|
|
bearingRef.current = 0
|
|
|
|
// Reset pitch and bearing with animation
|
|
map.easeTo({
|
|
pitch: BASE_PITCH,
|
|
bearing: 0,
|
|
duration: 1500,
|
|
easing: (t) => t * (2 - t), // easeOutQuad
|
|
})
|
|
|
|
// Show all clusters again when unfocusing
|
|
if (map.getMap().getLayer("clusters")) {
|
|
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
|
}
|
|
if (map.getMap().getLayer("unclustered-point")) {
|
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
if (!demographics) {
|
|
console.error("Missing demographics data for district:", districtId)
|
|
return
|
|
}
|
|
|
|
const district: DistrictFeature = {
|
|
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, // Mark this district as focused
|
|
}
|
|
|
|
if (!district.longitude || !district.latitude) {
|
|
console.error("Invalid district coordinates:", district)
|
|
return
|
|
}
|
|
|
|
selectedDistrictRef.current = district
|
|
setFocusedDistrictId(district.id)
|
|
persistentFocusedDistrictRef.current = district.id
|
|
isFocusedMode.current = true
|
|
console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current)
|
|
|
|
// Hide clusters when focusing on a district
|
|
if (map.getMap().getLayer("clusters")) {
|
|
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
|
}
|
|
if (map.getMap().getLayer("unclustered-point")) {
|
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
|
|
}
|
|
|
|
// Reset bearing before animation
|
|
bearingRef.current = 0
|
|
|
|
// Animate to a pitched view focused on the district
|
|
map.flyTo({
|
|
center: [district.longitude, district.latitude],
|
|
zoom: 12.5,
|
|
pitch: 75,
|
|
bearing: 0,
|
|
duration: 1500,
|
|
easing: (t) => t * (2 - t), // easeOutQuad
|
|
})
|
|
|
|
// Stop any existing rotation animation
|
|
if (rotationAnimationRef.current) {
|
|
cancelAnimationFrame(rotationAnimationRef.current)
|
|
rotationAnimationRef.current = null
|
|
}
|
|
|
|
// Improved continuous bearing rotation function
|
|
const startRotation = () => {
|
|
const rotationSpeed = 0.05 // degrees per frame - adjust for slower/faster rotation
|
|
|
|
const animate = () => {
|
|
// Check if map and focus are still valid
|
|
if (!map || !map.getMap() || focusedDistrictId !== district.id) {
|
|
if (rotationAnimationRef.current) {
|
|
cancelAnimationFrame(rotationAnimationRef.current)
|
|
rotationAnimationRef.current = null
|
|
}
|
|
return
|
|
}
|
|
|
|
// Update bearing with smooth increment
|
|
bearingRef.current = (bearingRef.current + rotationSpeed) % 360
|
|
map.getMap().setBearing(bearingRef.current) // Use map.getMap().setBearing instead of map.setBearing
|
|
|
|
// Continue the animation
|
|
rotationAnimationRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
// Start the animation loop after a short delay to ensure the flyTo has started
|
|
setTimeout(() => {
|
|
rotationAnimationRef.current = requestAnimationFrame(animate)
|
|
}, 100)
|
|
}
|
|
|
|
// Start rotation after the initial flyTo completes
|
|
setTimeout(startRotation, 1600)
|
|
|
|
if (onClick) {
|
|
onClick(district)
|
|
} else {
|
|
setSelectedDistrict(district)
|
|
}
|
|
}
|
|
|
|
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],
|
|
)
|
|
|
|
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 handleCloseDistrictPopup = useCallback(() => {
|
|
console.log("Closing district popup")
|
|
selectedDistrictRef.current = null
|
|
setSelectedDistrict(null)
|
|
setFocusedDistrictId(null)
|
|
persistentFocusedDistrictRef.current = null
|
|
isFocusedMode.current = false
|
|
|
|
// Cancel rotation animation when closing popup
|
|
if (rotationAnimationRef.current) {
|
|
cancelAnimationFrame(rotationAnimationRef.current)
|
|
rotationAnimationRef.current = null
|
|
}
|
|
|
|
bearingRef.current = 0
|
|
|
|
// 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.getMap().getLayer("clusters")) {
|
|
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
|
}
|
|
if (map.getMap().getLayer("unclustered-point")) {
|
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
|
}
|
|
}
|
|
}, [map])
|
|
|
|
useEffect(() => {
|
|
if (!map || !visible) return
|
|
|
|
const onStyleLoad = () => {
|
|
if (!map) return
|
|
|
|
try {
|
|
if (!map.getMap().getSource("districts")) {
|
|
const layers = map.getStyle().layers
|
|
let firstSymbolId: string | undefined
|
|
for (const layer of layers) {
|
|
if (layer.type === "symbol") {
|
|
firstSymbolId = layer.id
|
|
break
|
|
}
|
|
}
|
|
|
|
map.getMap().addSource("districts", {
|
|
type: "vector",
|
|
url: `mapbox://${tilesetId}`,
|
|
})
|
|
|
|
const fillColorExpression: any = [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
...(focusedDistrictId
|
|
? [
|
|
[
|
|
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,
|
|
],
|
|
"rgba(0,0,0,0.05)",
|
|
]
|
|
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
|
return [
|
|
districtId,
|
|
data.level === "low"
|
|
? CRIME_RATE_COLORS.low
|
|
: data.level === "medium"
|
|
? CRIME_RATE_COLORS.medium
|
|
: data.level === "high"
|
|
? CRIME_RATE_COLORS.high
|
|
: CRIME_RATE_COLORS.default,
|
|
]
|
|
})),
|
|
focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
|
|
],
|
|
CRIME_RATE_COLORS.default,
|
|
]
|
|
|
|
if (!map.getMap().getLayer("district-fill")) {
|
|
map.getMap().addLayer(
|
|
{
|
|
id: "district-fill",
|
|
type: "fill",
|
|
source: "districts",
|
|
"source-layer": "Districts",
|
|
paint: {
|
|
"fill-color": fillColorExpression,
|
|
"fill-opacity": 0.6,
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
if (!map.getMap().getLayer("district-line")) {
|
|
map.getMap().addLayer(
|
|
{
|
|
id: "district-line",
|
|
type: "line",
|
|
source: "districts",
|
|
"source-layer": "Districts",
|
|
paint: {
|
|
"line-color": "#ffffff",
|
|
"line-width": 1,
|
|
"line-opacity": 0.5,
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
if (!map.getMap().getLayer("district-extrusion")) {
|
|
map.getMap().addLayer(
|
|
{
|
|
id: "district-extrusion",
|
|
type: "fill-extrusion",
|
|
source: "districts",
|
|
"source-layer": "Districts",
|
|
paint: {
|
|
"fill-extrusion-color": [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["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",
|
|
],
|
|
"fill-extrusion-height": [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
["match", ["get", "kode_kec"], focusedDistrictId || "", 500, 0],
|
|
0,
|
|
],
|
|
"fill-extrusion-base": 0,
|
|
"fill-extrusion-opacity": 0.8,
|
|
},
|
|
filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""],
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
if (crimes.length > 0 && !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: "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,
|
|
},
|
|
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: "visible",
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
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 = ""
|
|
})
|
|
|
|
map.on("mouseenter", "district-fill", () => {
|
|
map.getCanvas().style.cursor = "pointer"
|
|
})
|
|
|
|
map.on("mouseleave", "district-fill", () => {
|
|
map.getCanvas().style.cursor = ""
|
|
})
|
|
|
|
map.off("click", "clusters", handleClusterClick)
|
|
map.off("click", "unclustered-point", handleIncidentClick)
|
|
|
|
map.on("click", "clusters", handleClusterClick)
|
|
map.on("click", "unclustered-point", handleIncidentClick)
|
|
|
|
map.off("click", "district-fill", handleDistrictClick)
|
|
map.on("click", "district-fill", handleDistrictClick)
|
|
|
|
map.on("mouseleave", "district-fill", () => setHoverInfo(null))
|
|
|
|
layersAdded.current = true
|
|
}
|
|
} else {
|
|
if (map.getMap().getLayer("district-fill")) {
|
|
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
...(focusedDistrictId
|
|
? [
|
|
[
|
|
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,
|
|
],
|
|
"rgba(0,0,0,0.05)",
|
|
]
|
|
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
|
return [
|
|
districtId,
|
|
data.level === "low"
|
|
? CRIME_RATE_COLORS.low
|
|
: data.level === "medium"
|
|
? CRIME_RATE_COLORS.medium
|
|
: data.level === "high"
|
|
? CRIME_RATE_COLORS.high
|
|
: CRIME_RATE_COLORS.default,
|
|
]
|
|
})),
|
|
focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
|
|
],
|
|
CRIME_RATE_COLORS.default,
|
|
] as any)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error adding district layers:", error)
|
|
}
|
|
}
|
|
|
|
if (map.isStyleLoaded()) {
|
|
onStyleLoad()
|
|
} else {
|
|
map.once("style.load", onStyleLoad)
|
|
}
|
|
|
|
return () => {
|
|
if (map) {
|
|
map.off("click", "district-fill", handleDistrictClick)
|
|
}
|
|
}
|
|
}, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick])
|
|
|
|
useEffect(() => {
|
|
if (!map || !layersAdded.current) return
|
|
|
|
try {
|
|
if (map.getMap().getLayer("district-fill")) {
|
|
const colorEntries = focusedDistrictId
|
|
? [
|
|
[
|
|
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,
|
|
],
|
|
"rgba(0,0,0,0.05)",
|
|
]
|
|
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
|
if (!data || !data.level) {
|
|
return [districtId, CRIME_RATE_COLORS.default]
|
|
}
|
|
|
|
return [
|
|
districtId,
|
|
data.level === "low"
|
|
? CRIME_RATE_COLORS.low
|
|
: data.level === "medium"
|
|
? CRIME_RATE_COLORS.medium
|
|
: data.level === "high"
|
|
? CRIME_RATE_COLORS.high
|
|
: CRIME_RATE_COLORS.default,
|
|
]
|
|
})
|
|
|
|
const fillColorExpression = [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
...colorEntries,
|
|
focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
|
|
],
|
|
CRIME_RATE_COLORS.default,
|
|
] as any
|
|
|
|
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression)
|
|
}
|
|
|
|
if (map.getMap().getLayer("district-extrusion")) {
|
|
map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""])
|
|
|
|
map
|
|
.getMap()
|
|
.setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["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) {
|
|
const startHeight = 0
|
|
const targetHeight = 800
|
|
const duration = 700
|
|
const startTime = performance.now()
|
|
|
|
const animateHeight = (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"], 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)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error updating district layer:", error)
|
|
}
|
|
}, [map, crimes, crimeDataByDistrict, focusedDistrictId])
|
|
|
|
useEffect(() => {
|
|
if (!map || !map.getMap().getSource("crime-incidents")) return
|
|
|
|
try {
|
|
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)
|
|
})
|
|
; (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])
|
|
|
|
useEffect(() => {
|
|
if (selectedDistrictRef.current) {
|
|
const districtId = selectedDistrictRef.current.id
|
|
const crimeData = crimeDataByDistrict[districtId] || {}
|
|
|
|
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: DistrictFeature = {
|
|
...selectedDistrictRef.current,
|
|
number_of_crime: crimeData.number_of_crime || 0,
|
|
level: crimeData.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,
|
|
isFocused: true, // Ensure this stays true
|
|
}
|
|
|
|
selectedDistrictRef.current = updatedDistrict
|
|
|
|
setSelectedDistrict((prevDistrict) => {
|
|
if (
|
|
prevDistrict?.id === updatedDistrict.id &&
|
|
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
|
|
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
|
|
) {
|
|
return prevDistrict
|
|
}
|
|
return updatedDistrict
|
|
})
|
|
|
|
if (districtId === persistentFocusedDistrictRef.current && !focusedDistrictId) {
|
|
setFocusedDistrictId(districtId)
|
|
}
|
|
}
|
|
}
|
|
}, [crimes, filterCategory, year, month, crimeDataByDistrict, focusedDistrictId])
|
|
|
|
useEffect(() => {
|
|
if (!map || !layersAdded.current || !persistentFocusedDistrictRef.current) return
|
|
|
|
console.log("Filter changed, maintaining focus for district:", persistentFocusedDistrictRef.current)
|
|
|
|
if (focusedDistrictId !== persistentFocusedDistrictRef.current) {
|
|
setFocusedDistrictId(persistentFocusedDistrictRef.current)
|
|
}
|
|
|
|
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 (!rotationAnimationRef.current && persistentFocusedDistrictRef.current) {
|
|
const startRotation = () => {
|
|
const rotationSpeed = 0.05
|
|
|
|
const animate = () => {
|
|
if (!map || !map.getMap() || !persistentFocusedDistrictRef.current) {
|
|
if (rotationAnimationRef.current) {
|
|
cancelAnimationFrame(rotationAnimationRef.current)
|
|
rotationAnimationRef.current = null
|
|
}
|
|
return
|
|
}
|
|
|
|
bearingRef.current = (bearingRef.current + rotationSpeed) % 360
|
|
map.getMap().setBearing(bearingRef.current)
|
|
|
|
rotationAnimationRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
rotationAnimationRef.current = requestAnimationFrame(animate)
|
|
}
|
|
|
|
startRotation()
|
|
}
|
|
|
|
if (map.getMap().getLayer("district-extrusion")) {
|
|
map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], persistentFocusedDistrictRef.current])
|
|
|
|
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
persistentFocusedDistrictRef.current,
|
|
crimeDataByDistrict[persistentFocusedDistrictRef.current]?.level === "low"
|
|
? CRIME_RATE_COLORS.low
|
|
: crimeDataByDistrict[persistentFocusedDistrictRef.current]?.level === "medium"
|
|
? CRIME_RATE_COLORS.medium
|
|
: crimeDataByDistrict[persistentFocusedDistrictRef.current]?.level === "high"
|
|
? CRIME_RATE_COLORS.high
|
|
: CRIME_RATE_COLORS.default,
|
|
"transparent",
|
|
],
|
|
"transparent",
|
|
])
|
|
|
|
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
["match", ["get", "kode_kec"], persistentFocusedDistrictRef.current, 800, 0],
|
|
0,
|
|
])
|
|
}
|
|
|
|
if (map.getPitch() !== 75) {
|
|
map.easeTo({
|
|
pitch: 75,
|
|
duration: 500,
|
|
})
|
|
}
|
|
}, [map, year, month, filterCategory, crimes, focusedDistrictId, crimeDataByDistrict])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (rotationAnimationRef.current) {
|
|
cancelAnimationFrame(rotationAnimationRef.current)
|
|
rotationAnimationRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<>
|
|
{selectedDistrict && (
|
|
<DistrictPopup
|
|
longitude={selectedDistrict.longitude || 0}
|
|
latitude={selectedDistrict.latitude || 0}
|
|
onClose={handleCloseDistrictPopup}
|
|
district={selectedDistrict}
|
|
year={year}
|
|
month={month}
|
|
filterCategory={filterCategory}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|