MIF_E31221222/sigap-website/app/_components/map/layers/district-layer.tsx

611 lines
24 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
filterCategory?: string | "all"
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,
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 [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) => {
// Apply category filter if specified
let filteredIncidents = crime.incidents;
if (filterCategory !== "all") {
filteredIncidents = crime.incidents.filter(
incident => incident.category === filterCategory
);
}
return filteredIncidents.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, filterCategory])
// 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])
// Update the incident data when it changes
useEffect(() => {
if (!map || !map.getMap().getSource("crime-incidents")) return;
try {
// Get all incidents, filtered by category if needed
const allIncidents = crimes.flatMap((crime) => {
// Apply category filter if specified
let filteredIncidents = crime.incidents;
if (filterCategory !== "all") {
filteredIncidents = crime.incidents.filter(
incident => incident.category === filterCategory
);
}
return filteredIncidents.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],
},
}));
});
// Update the data source
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection",
features: allIncidents,
});
} catch (error) {
console.error("Error updating incident data:", error);
}
}, [map, crimes, filterCategory]);
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}
/>
)}
</>
)
}