559 lines
22 KiB
TypeScript
559 lines
22 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useRef } from "react"
|
|
import { useMap } from "react-map-gl/mapbox"
|
|
import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
|
|
import { DistrictPopup } from "../pop-up"
|
|
|
|
// Types for district properties
|
|
export interface DistrictFeature {
|
|
id: string
|
|
name: string
|
|
properties: Record<string, any>
|
|
longitude?: number
|
|
latitude?: number
|
|
number_of_crime?: number
|
|
level?: "low" | "medium" | "high" | "critical"
|
|
}
|
|
|
|
// District layer props
|
|
export interface DistrictLayerProps {
|
|
visible?: boolean
|
|
onClick?: (feature: DistrictFeature) => void
|
|
year?: string
|
|
month?: string
|
|
crimes?: Array<{
|
|
id: string
|
|
district_name: string
|
|
distrcit_id?: string
|
|
number_of_crime?: number
|
|
level?: "low" | "medium" | "high" | "critical"
|
|
incidents: any[]
|
|
}>
|
|
tilesetId?: string
|
|
}
|
|
|
|
export default function DistrictLayer({
|
|
visible = true,
|
|
onClick,
|
|
year,
|
|
month,
|
|
crimes = [],
|
|
tilesetId = MAPBOX_TILESET_ID,
|
|
}: DistrictLayerProps) {
|
|
const { current: map } = useMap()
|
|
|
|
const [hoverInfo, setHoverInfo] = useState<{
|
|
x: number
|
|
y: number
|
|
feature: any
|
|
} | null>(null)
|
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
|
|
|
// Use a ref to track whether layers have been added
|
|
const layersAdded = useRef(false)
|
|
|
|
// Process crime data to map to districts by district_id (kode_kec)
|
|
const crimeDataByDistrict = crimes.reduce(
|
|
(acc, crime) => {
|
|
// Use district_id (which corresponds to kode_kec in the tileset) as the key
|
|
const districtId = crime.distrcit_id || crime.district_name
|
|
|
|
console.log("Mapping district:", districtId, "level:", crime.level)
|
|
|
|
acc[districtId] = {
|
|
number_of_crime: crime.number_of_crime,
|
|
level: crime.level,
|
|
}
|
|
return acc
|
|
},
|
|
{} as Record<string, { number_of_crime?: number; level?: "low" | "medium" | "high" | "critical" }>,
|
|
)
|
|
|
|
// Handle click on district
|
|
const handleClick = (e: any) => {
|
|
if (!map || !e.features || e.features.length === 0) return
|
|
|
|
const feature = e.features[0]
|
|
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
|
const crimeData = crimeDataByDistrict[districtId] || {}
|
|
|
|
const district: DistrictFeature = {
|
|
id: districtId,
|
|
name: feature.properties.nama || feature.properties.kecamatan,
|
|
properties: feature.properties,
|
|
longitude: e.lngLat.lng,
|
|
latitude: e.lngLat.lat,
|
|
...crimeData,
|
|
}
|
|
|
|
if (onClick) {
|
|
onClick(district)
|
|
} else {
|
|
setSelectedDistrict(district)
|
|
}
|
|
}
|
|
|
|
// Handle mouse move for hover effect
|
|
const handleMouseMove = (e: any) => {
|
|
if (!map || !e.features || e.features.length === 0) return
|
|
|
|
const feature = e.features[0]
|
|
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
|
const crimeData = crimeDataByDistrict[districtId] || {}
|
|
|
|
console.log("Hover district:", districtId, "found data:", crimeData)
|
|
|
|
// Enhance feature with crime data
|
|
feature.properties = {
|
|
...feature.properties,
|
|
...crimeData,
|
|
}
|
|
|
|
setHoverInfo({
|
|
x: e.point.x,
|
|
y: e.point.y,
|
|
feature: feature,
|
|
})
|
|
}
|
|
|
|
// Add district layer to the map when it's loaded
|
|
useEffect(() => {
|
|
if (!map || !visible) return
|
|
|
|
// Handler for style load event
|
|
const onStyleLoad = () => {
|
|
// Skip if map is not available
|
|
if (!map) return
|
|
|
|
try {
|
|
// Check if the source already exists to prevent duplicates
|
|
if (!map.getMap().getSource("districts")) {
|
|
// Get the first symbol layer ID from the map style
|
|
// This ensures our layers appear below labels and POIs
|
|
const layers = map.getStyle().layers
|
|
let firstSymbolId: string | undefined
|
|
for (const layer of layers) {
|
|
if (layer.type === "symbol") {
|
|
firstSymbolId = layer.id
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add the vector tile source
|
|
map.getMap().addSource("districts", {
|
|
type: "vector",
|
|
url: `mapbox://${tilesetId}`,
|
|
})
|
|
|
|
// Create the dynamic fill color expression based on crime data
|
|
const fillColorExpression: any = [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
|
console.log("Initial color setting for:", districtId, "level:", data.level)
|
|
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,
|
|
]
|
|
}),
|
|
CRIME_RATE_COLORS.default,
|
|
],
|
|
CRIME_RATE_COLORS.default,
|
|
]
|
|
|
|
// Only add layers if they don't already exist
|
|
if (!map.getMap().getLayer("district-fill")) {
|
|
// Add the fill layer for districts with dynamic colors from the start
|
|
// Insert below the first symbol layer to preserve Mapbox default layers
|
|
map.getMap().addLayer(
|
|
{
|
|
id: "district-fill",
|
|
type: "fill",
|
|
source: "districts",
|
|
"source-layer": "Districts",
|
|
paint: {
|
|
"fill-color": fillColorExpression, // Apply colors based on crime data
|
|
"fill-opacity": 0.6,
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
) // Add before the first symbol layer
|
|
}
|
|
|
|
if (!map.getMap().getLayer("district-line")) {
|
|
// Add the line layer for district borders
|
|
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-labels")) {
|
|
// // Add district labels with improved visibility and responsive sizing
|
|
// map.getMap().addLayer(
|
|
// {
|
|
// id: "district-labels",
|
|
// type: "symbol",
|
|
// source: "districts",
|
|
// "source-layer": "Districts",
|
|
// layout: {
|
|
// "text-field": ["get", "nama"],
|
|
// "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
|
|
// // Make text size responsive to zoom level
|
|
// "text-size": [
|
|
// "interpolate",
|
|
// ["linear"],
|
|
// ["zoom"],
|
|
// 9,
|
|
// 8, // At zoom level 9, size 8px
|
|
// 12,
|
|
// 12, // At zoom level 12, size 12px
|
|
// 15,
|
|
// 14, // At zoom level 15, size 14px
|
|
// ],
|
|
// "text-allow-overlap": false,
|
|
// "text-ignore-placement": false,
|
|
// // Adjust text anchor based on zoom level
|
|
// "text-anchor": "center",
|
|
// "text-justify": "center",
|
|
// "text-max-width": 8,
|
|
// // Show labels only at certain zoom levels
|
|
// "text-optional": true,
|
|
// "symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
|
|
// "symbol-z-order": "source",
|
|
// },
|
|
// paint: {
|
|
// "text-color": "#000000",
|
|
// "text-halo-color": "#ffffff",
|
|
// "text-halo-width": 2,
|
|
// "text-halo-blur": 1,
|
|
// // Fade in text opacity based on zoom level
|
|
// "text-opacity": [
|
|
// "interpolate",
|
|
// ["linear"],
|
|
// ["zoom"],
|
|
// 8,
|
|
// 0, // Fully transparent at zoom level 8
|
|
// 9,
|
|
// 0.6, // 60% opacity at zoom level 9
|
|
// 10,
|
|
// 1.0, // Fully opaque at zoom level 10
|
|
// ],
|
|
// },
|
|
// },
|
|
// firstSymbolId,
|
|
// )
|
|
// }
|
|
|
|
// Create a source for clustered incident markers
|
|
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
|
|
// Collect all incidents from all districts
|
|
const allIncidents = crimes.flatMap((crime) =>
|
|
crime.incidents.map((incident) => ({
|
|
type: "Feature" as const,
|
|
properties: {
|
|
id: incident.id,
|
|
district: crime.district_name,
|
|
category: incident.category,
|
|
incidentType: incident.type,
|
|
level: crime.level,
|
|
description: incident.description,
|
|
},
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [incident.longitude, incident.latitude],
|
|
},
|
|
})),
|
|
)
|
|
|
|
// Add a clustered GeoJSON source for incidents
|
|
map.getMap().addSource("crime-incidents", {
|
|
type: "geojson",
|
|
data: {
|
|
type: "FeatureCollection",
|
|
features: allIncidents,
|
|
},
|
|
cluster: true,
|
|
clusterMaxZoom: 14,
|
|
clusterRadius: 50,
|
|
})
|
|
|
|
// Only add layers if they don't already exist
|
|
if (!map.getMap().getLayer("clusters")) {
|
|
// Add a layer for the clusters - place below default symbol layers
|
|
map.getMap().addLayer(
|
|
{
|
|
id: "clusters",
|
|
type: "circle",
|
|
source: "crime-incidents",
|
|
filter: ["has", "point_count"],
|
|
paint: {
|
|
"circle-color": [
|
|
"step",
|
|
["get", "point_count"],
|
|
"#51bbd6", // Blue for small clusters
|
|
5,
|
|
"#f1f075", // Yellow for medium clusters
|
|
15,
|
|
"#f28cb1", // Pink for large clusters
|
|
],
|
|
"circle-radius": [
|
|
"step",
|
|
["get", "point_count"],
|
|
20, // Size for small clusters
|
|
5,
|
|
30, // Size for medium clusters
|
|
15,
|
|
40, // Size for large clusters
|
|
],
|
|
"circle-opacity": 0.75,
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
if (!map.getMap().getLayer("cluster-count")) {
|
|
// Add a layer for cluster counts
|
|
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")) {
|
|
// Add a layer for individual incident points
|
|
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",
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
// Add click handler for clusters
|
|
map.on("click", "clusters", (e) => {
|
|
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
|
|
|
if (!features || features.length === 0) return
|
|
|
|
const clusterId = features[0].properties?.cluster_id
|
|
|
|
// Get the cluster expansion zoom
|
|
; (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,
|
|
})
|
|
},
|
|
)
|
|
})
|
|
|
|
// Show pointer cursor on clusters and points
|
|
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 = ""
|
|
})
|
|
}
|
|
|
|
// Set event handlers
|
|
map.on("click", "district-fill", handleClick)
|
|
map.on("mousemove", "district-fill", handleMouseMove)
|
|
map.on("mouseleave", "district-fill", () => setHoverInfo(null))
|
|
|
|
// Mark layers as added
|
|
layersAdded.current = true
|
|
console.log("District layers added successfully")
|
|
} else {
|
|
// If the source already exists, just update the data
|
|
console.log("District source already exists, updating data")
|
|
|
|
// Update the district-fill layer with new crime data if it exists
|
|
if (map.getMap().getLayer("district-fill")) {
|
|
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
...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,
|
|
]
|
|
}),
|
|
CRIME_RATE_COLORS.default,
|
|
],
|
|
CRIME_RATE_COLORS.default,
|
|
] as any)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error adding district layers:", error)
|
|
}
|
|
}
|
|
|
|
// If the map's style is already loaded, add the layers immediately
|
|
if (map.isStyleLoaded()) {
|
|
onStyleLoad()
|
|
} else {
|
|
// Otherwise, wait for the style.load event
|
|
map.once("style.load", onStyleLoad)
|
|
}
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
if (map) {
|
|
// Only remove event listeners, not the layers themselves
|
|
map.off("click", "district-fill", handleClick)
|
|
map.off("mousemove", "district-fill", handleMouseMove)
|
|
map.off("mouseleave", "district-fill", () => setHoverInfo(null))
|
|
|
|
// We're not removing the layers or sources here to avoid disrupting the map
|
|
// This prevents the issue of removing default layers
|
|
}
|
|
}
|
|
}, [map, visible, tilesetId, crimes])
|
|
|
|
// Update the crime data when it changes
|
|
useEffect(() => {
|
|
if (!map || !layersAdded.current) return
|
|
|
|
console.log("Updating district colors with data:", crimeDataByDistrict)
|
|
|
|
// Update the district-fill layer with new crime data
|
|
try {
|
|
// Check if the layer exists before updating it
|
|
if (map.getMap().getLayer("district-fill")) {
|
|
// We need to update the layer paint property to correctly apply colors
|
|
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
|
"case",
|
|
["has", "kode_kec"],
|
|
[
|
|
"match",
|
|
["get", "kode_kec"],
|
|
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
|
console.log("Setting color for:", districtId, "level:", data.level)
|
|
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,
|
|
]
|
|
}),
|
|
CRIME_RATE_COLORS.default,
|
|
],
|
|
CRIME_RATE_COLORS.default,
|
|
] as any)
|
|
}
|
|
} catch (error) {
|
|
console.error("Error updating district layer:", error)
|
|
}
|
|
}, [map, crimes])
|
|
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<>
|
|
{/* Hover tooltip */}
|
|
{hoverInfo && (
|
|
<div
|
|
className="absolute z-10 bg-white rounded-md shadow-md px-3 py-2 pointer-events-none"
|
|
style={{
|
|
left: hoverInfo.x + 10,
|
|
top: hoverInfo.y + 10,
|
|
}}
|
|
>
|
|
<p className="text-sm font-medium">
|
|
{hoverInfo.feature.properties.nama || hoverInfo.feature.properties.kecamatan}
|
|
</p>
|
|
{hoverInfo.feature.properties.number_of_crime !== undefined && (
|
|
<p className="text-xs text-gray-600">
|
|
{hoverInfo.feature.properties.number_of_crime} incidents
|
|
{hoverInfo.feature.properties.level && (
|
|
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* District popup */}
|
|
{selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && (
|
|
<DistrictPopup
|
|
longitude={selectedDistrict.longitude}
|
|
latitude={selectedDistrict.latitude}
|
|
onClose={() => setSelectedDistrict(null)}
|
|
district={selectedDistrict}
|
|
year={year}
|
|
month={month}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|