From 7d01547a02de6908603a504069c54122197d768f Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sun, 4 May 2025 02:45:10 +0700 Subject: [PATCH] Add DistrictLayer and MapLayerManager components for enhanced map functionality - Implemented DistrictLayer to display district information and crime data on the map. - Added interactivity for district selection, including animations and data retrieval. - Created MapLayerManager to manage multiple layers on the map, including district and crime layers. - Integrated crime data processing and visualization based on user interactions. - Ensured proper handling of map style loading and layer management. --- .../map/controls/crime-timelapse.tsx | 158 +- .../app/_components/map/crime-map.tsx | 13 +- .../app/_components/map/layers/base-layer.tsx | 31 + .../map/layers/crime-cluster-layer.tsx | 337 ++++ .../map/layers/district-extrusion-layer.tsx | 194 ++ .../map/layers/district-layer-old.tsx | 1032 ++++++++++ .../_components/map/layers/district-layer.tsx | 1740 ++++++++--------- .../map/layers/map-layer-manager.tsx | 226 +++ 8 files changed, 2666 insertions(+), 1065 deletions(-) create mode 100644 sigap-website/app/_components/map/layers/base-layer.tsx create mode 100644 sigap-website/app/_components/map/layers/crime-cluster-layer.tsx create mode 100644 sigap-website/app/_components/map/layers/district-extrusion-layer.tsx create mode 100644 sigap-website/app/_components/map/layers/district-layer-old.tsx create mode 100644 sigap-website/app/_components/map/layers/map-layer-manager.tsx diff --git a/sigap-website/app/_components/map/controls/crime-timelapse.tsx b/sigap-website/app/_components/map/controls/crime-timelapse.tsx index a55aeb0..192002b 100644 --- a/sigap-website/app/_components/map/controls/crime-timelapse.tsx +++ b/sigap-website/app/_components/map/controls/crime-timelapse.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react" +import { useState, useEffect, useRef } from "react" import { Pause, Play } from "lucide-react" import { Button } from "@/app/_components/ui/button" import { cn } from "@/app/_lib/utils" @@ -13,7 +13,6 @@ interface CrimeTimelapseProps { className?: string autoPlay?: boolean autoPlaySpeed?: number // Time to progress through one month in ms - enablePerformanceMode?: boolean // Flag untuk mode performa } export function CrimeTimelapse({ @@ -24,7 +23,6 @@ export function CrimeTimelapse({ className, autoPlay = true, autoPlaySpeed = 1000, // Speed of month progress - enablePerformanceMode = true, // Default aktifkan mode performa tinggi }: CrimeTimelapseProps) { const [currentYear, setCurrentYear] = useState(startYear) const [currentMonth, setCurrentMonth] = useState(1) // Start at January (1) @@ -33,23 +31,25 @@ export function CrimeTimelapse({ const [isDragging, setIsDragging] = useState(false) const animationRef = useRef(null) const lastUpdateTimeRef = useRef(0) - const frameSkipCountRef = useRef(0) // Untuk pelompatan frame - // Jumlah frame yang akan dilewati saat performance mode aktif - const frameSkipThreshold = enablePerformanceMode ? 3 : 0 + // Notify parent about playing state changes + useEffect(() => { + if (onPlayingChange) { + onPlayingChange(isPlaying || isDragging) + } + }, [isPlaying, isDragging, onPlayingChange]) - // Hitung total bulan dari awal hingga akhir tahun (memoisasi) - const totalMonths = useRef(((endYear - startYear) * 12) + 12) // +12 untuk memasukkan semua bulan tahun akhir + // Calculate total months from start to end year + const totalMonths = ((endYear - startYear) * 12) + 12 // +12 to include all months of end year - // Menggunakan useCallback untuk fungsi yang sering dipanggil - const calculateOverallProgress = useCallback((): number => { + const calculateOverallProgress = (): number => { const yearDiff = currentYear - startYear const monthProgress = (yearDiff * 12) + (currentMonth - 1) - return ((monthProgress + progress) / (totalMonths.current - 1)) * 100 - }, [currentYear, currentMonth, progress, startYear, totalMonths]) + return ((monthProgress + progress) / (totalMonths - 1)) * 100 + } - const calculateTimeFromProgress = useCallback((overallProgress: number): { year: number; month: number; progress: number } => { - const totalProgress = (overallProgress * (totalMonths.current - 1)) / 100 + const calculateTimeFromProgress = (overallProgress: number): { year: number; month: number; progress: number } => { + const totalProgress = (overallProgress * (totalMonths - 1)) / 100 const monthsFromStart = Math.floor(totalProgress) const year = startYear + Math.floor(monthsFromStart / 12) @@ -61,72 +61,61 @@ export function CrimeTimelapse({ month: Math.min(month, 12), progress: monthProgress } - }, [startYear, endYear, totalMonths]) + } // Calculate the current position for the active marker - const calculateMarkerPosition = useCallback((): string => { - return `${calculateOverallProgress()}%` - }, [calculateOverallProgress]) + const calculateMarkerPosition = (): string => { + const overallProgress = calculateOverallProgress() + return `${overallProgress}%` + } - // Optimasi animasi dengan throttling berbasis requestAnimationFrame - const animate = useCallback((timestamp: number) => { + const animate = (timestamp: number) => { if (!lastUpdateTimeRef.current) { lastUpdateTimeRef.current = timestamp } if (!isDragging) { - // Performance optimization: skip frames in performance mode - frameSkipCountRef.current++ + const elapsed = timestamp - lastUpdateTimeRef.current + const progressIncrement = elapsed / autoPlaySpeed - if (frameSkipCountRef.current > frameSkipThreshold || !enablePerformanceMode) { - frameSkipCountRef.current = 0 + let newProgress = progress + progressIncrement + let newMonth = currentMonth + let newYear = currentYear - const elapsed = timestamp - lastUpdateTimeRef.current - const progressIncrement = elapsed / autoPlaySpeed + if (newProgress >= 1) { + newProgress = 0 + newMonth = currentMonth + 1 - let newProgress = progress + progressIncrement - let newMonth = currentMonth - let newYear = currentYear + if (newMonth > 12) { + newMonth = 1 + newYear = currentYear + 1 - if (newProgress >= 1) { - newProgress = 0 - newMonth = currentMonth + 1 - - if (newMonth > 12) { + if (newYear > endYear) { + newYear = startYear newMonth = 1 - newYear = currentYear + 1 - - if (newYear > endYear) { - newYear = startYear - newMonth = 1 - } } - - setCurrentMonth(newMonth) - setCurrentYear(newYear) } - setProgress(newProgress) - - // Notify parent component only after calculating all changes - if (onChange) { - onChange(newYear, newMonth, newProgress) - } - - lastUpdateTimeRef.current = timestamp + setCurrentMonth(newMonth) + setCurrentYear(newYear) } + + setProgress(newProgress) + if (onChange) { + onChange(newYear, newMonth, newProgress) + } + + lastUpdateTimeRef.current = timestamp } if (isPlaying) { animationRef.current = requestAnimationFrame(animate) } - }, [isPlaying, isDragging, progress, currentMonth, currentYear, onChange, - autoPlaySpeed, startYear, endYear, enablePerformanceMode, frameSkipThreshold]) + } useEffect(() => { if (isPlaying) { lastUpdateTimeRef.current = 0 - frameSkipCountRef.current = 0 animationRef.current = requestAnimationFrame(animate) } else if (animationRef.current) { cancelAnimationFrame(animationRef.current) @@ -137,21 +126,17 @@ export function CrimeTimelapse({ cancelAnimationFrame(animationRef.current) } } - }, [isPlaying, animate]) + }, [isPlaying, currentYear, currentMonth, progress, isDragging]) - // Memoized handler for play/pause - const handlePlayPause = useCallback(() => { - setIsPlaying(prevState => { - const newPlayingState = !prevState - if (onPlayingChange) { - onPlayingChange(newPlayingState) - } - return newPlayingState - }) - }, [onPlayingChange]) + const handlePlayPause = () => { + const newPlayingState = !isPlaying + setIsPlaying(newPlayingState) + if (onPlayingChange) { + onPlayingChange(newPlayingState) + } + } - // Memoized handler for slider change - const handleSliderChange = useCallback((value: number[]) => { + const handleSliderChange = (value: number[]) => { const overallProgress = value[0] const { year, month, progress } = calculateTimeFromProgress(overallProgress) @@ -162,47 +147,42 @@ export function CrimeTimelapse({ if (onChange) { onChange(year, month, progress) } - }, [calculateTimeFromProgress, onChange]) + } - const handleSliderDragStart = useCallback(() => { + const handleSliderDragStart = () => { setIsDragging(true) if (onPlayingChange) { onPlayingChange(true) // Treat dragging as a form of "playing" for performance optimization } - }, [onPlayingChange]) + } - const handleSliderDragEnd = useCallback(() => { + const handleSliderDragEnd = () => { setIsDragging(false) if (onPlayingChange) { onPlayingChange(isPlaying) // Restore to actual playing state } - }, [isPlaying, onPlayingChange]) + } - // Komputasi tahun marker dilakukan sekali saja dan di-cache - const yearMarkers = useRef([]) - useEffect(() => { - const markers = [] - for (let year = startYear; year <= endYear; year++) { - markers.push(year) - } - yearMarkers.current = markers - }, [startYear, endYear]) - - // Gunakan React.memo untuk komponen child jika diperlukan - // Contoh: const YearMarker = React.memo(({ year, isActive }) => { ... }) + // Create year markers + const yearMarkers = [] + for (let year = startYear; year <= endYear; year++) { + yearMarkers.push(year) + } return (
- {/* Current month/year marker that moves with the slider */} + + {isPlaying && (
- {isPlaying}{getMonthName(currentMonth)} {currentYear} + {getMonthName(currentMonth)} {currentYear}
+ )} {/* Wrap button and slider in their container */}
@@ -234,12 +214,12 @@ export function CrimeTimelapse({ {/* Year markers */}
- {yearMarkers.current.map((year, index) => ( + {yearMarkers.map((year, index) => (
) -} \ No newline at end of file +} diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 1ee8c13..bbbec20 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" import { Skeleton } from "@/app/_components/ui/skeleton" -import DistrictLayer, { type DistrictFeature } from "./layers/district-layer" +import DistrictLayer, { type DistrictFeature } from "./layers/district-layer-old" import MapView from "./map" import { Button } from "@/app/_components/ui/button" import { AlertCircle } from "lucide-react" @@ -260,19 +260,8 @@ export default function CrimeMap() { year={selectedYear.toString()} month={selectedMonth.toString()} filterCategory={selectedCategory} - isTimelapsePlaying={isTimelapsePlaying} - /> - {/* Pass onClick if you want to handle districts externally */} - {/* - - */} {/* Popup for selected incident */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( diff --git a/sigap-website/app/_components/map/layers/base-layer.tsx b/sigap-website/app/_components/map/layers/base-layer.tsx new file mode 100644 index 0000000..7d60603 --- /dev/null +++ b/sigap-website/app/_components/map/layers/base-layer.tsx @@ -0,0 +1,31 @@ +"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 } +} diff --git a/sigap-website/app/_components/map/layers/crime-cluster-layer.tsx b/sigap-website/app/_components/map/layers/crime-cluster-layer.tsx new file mode 100644 index 0000000..5659a5a --- /dev/null +++ b/sigap-website/app/_components/map/layers/crime-cluster-layer.tsx @@ -0,0 +1,337 @@ +"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 +} diff --git a/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx b/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx new file mode 100644 index 0000000..9ddeebe --- /dev/null +++ b/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx @@ -0,0 +1,194 @@ +"use client" + +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 + tilesetId: string + beforeId?: string +} + +export default function DistrictExtrusionLayer({ + visible = true, + focusedDistrictId, + crimeDataByDistrict, + tilesetId, + beforeId, +}: DistrictExtrusionLayerProps) { + const { current: map } = useMap() + const layersAdded = useRef(false) + + useEffect(() => { + if (!map || !visible) return + + const onStyleLoad = () => { + if (!map) return + + try { + // Make sure the districts source exists + if (!map.getMap().getSource("districts")) { + map.getMap().addSource("districts", { + type: "vector", + url: `mapbox://${tilesetId}`, + }) + } + + // 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")) { + 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, + ) + layersAdded.current = true + } + } catch (error) { + console.error("Error adding extrusion layer:", error) + } + } + + if (map.isStyleLoaded()) { + onStyleLoad() + } else { + map.once("style.load", onStyleLoad) + } + }, [map, visible, tilesetId, beforeId]) + + useEffect(() => { + if (!map || !layersAdded.current) return + + try { + 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 extrusion layer:", error) + } + }, [map, focusedDistrictId, crimeDataByDistrict]) + + return null +} diff --git a/sigap-website/app/_components/map/layers/district-layer-old.tsx b/sigap-website/app/_components/map/layers/district-layer-old.tsx new file mode 100644 index 0000000..7307d22 --- /dev/null +++ b/sigap-website/app/_components/map/layers/district-layer-old.tsx @@ -0,0 +1,1032 @@ +"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(null) + const [selectedDistrict, setSelectedDistrict] = useState(null) + const [focusedDistrictId, setFocusedDistrictId] = useState(null) + const rotationAnimationRef = useRef(null) + const bearingRef = useRef(0) + const layersAdded = 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, + ) + + 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) + 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) + 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) // Clear the focus when popup is closed + + // 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, + } + + 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]) + + useEffect(() => { + return () => { + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + } + }, []) + + if (!visible) return null + + return ( + <> + {selectedDistrict && ( + + )} + + ) +} diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index a704ce2..4aa8b04 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -54,7 +54,6 @@ export interface DistrictLayerProps { filterCategory: string | "all" crimes: ICrimes[] tilesetId?: string - isTimelapsePlaying?: boolean // Add new prop to track timeline playing state } export default function DistrictLayer({ @@ -65,7 +64,6 @@ export default function DistrictLayer({ filterCategory = "all", crimes = [], tilesetId = MAPBOX_TILESET_ID, - isTimelapsePlaying = false, // Default to false }: DistrictLayerProps) { const { current: map } = useMap() @@ -78,372 +76,610 @@ export default function DistrictLayer({ const selectedDistrictRef = useRef(null) const [selectedDistrict, setSelectedDistrict] = useState(null) const [focusedDistrictId, setFocusedDistrictId] = useState(null) - const persistentFocusedDistrictRef = useRef(null) const rotationAnimationRef = useRef(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, - ) + acc[districtId] = { + number_of_crime: crime.number_of_crime, + level: crime.level, + } + return acc + }, + {} as Record, + ) const handleDistrictClick = (e: any) => { const incidentFeatures = map?.queryRenderedFeatures(e.point, { layers: ["unclustered-point", "clusters"] }) - if (incidentFeatures && incidentFeatures.length > 0) { - return + 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) + selectedDistrictRef.current = null + setSelectedDistrict(null) + + // Reset animation and map view when deselecting + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null } - 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, + // Reset pitch and bearing with animation + map.easeTo({ + pitch: BASE_PITCH, bearing: 0, duration: 1500, easing: (t) => t * (2 - t), // easeOutQuad }) - // Stop any existing rotation animation - if (rotationAnimationRef.current) { - cancelAnimationFrame(rotationAnimationRef.current) - rotationAnimationRef.current = null + // 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") } - // Improved continuous bearing rotation function - const startRotation = () => { - const rotationSpeed = 0.05 // degrees per frame - adjust for slower/faster rotation + return + } - 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 + 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) + 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 } - - // 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) + return } - // Start the animation loop after a short delay to ensure the flyTo has started - setTimeout(() => { - rotationAnimationRef.current = requestAnimationFrame(animate) - }, 100) - } + // Update bearing with smooth increment + bearingRef.current = (bearingRef.current + rotationSpeed) % 360 + map.getMap().setBearing(bearingRef.current) // Use map.getMap().setBearing instead of map.setBearing - // Start rotation after the initial flyTo completes - setTimeout(startRotation, 1600) + // Continue the animation + rotationAnimationRef.current = requestAnimationFrame(animate) + } - if (onClick) { - onClick(district) - } else { - setSelectedDistrict(district) - } + // 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 features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) + if (!features || features.length === 0) return - const incident = features[0] - if (!incident.properties) return + const incident = features[0] + if (!incident.properties) return - e.originalEvent.stopPropagation() - e.preventDefault() + 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(), - } + 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) + console.log("Incident clicked:", incidentDetails) - const customEvent = new CustomEvent("incident_click", { - detail: incidentDetails, - bubbles: true, - }) + const customEvent = new CustomEvent("incident_click", { + detail: incidentDetails, + bubbles: true, + }) - if (map.getMap().getCanvas()) { - map.getMap().getCanvas().dispatchEvent(customEvent) - } else { - document.dispatchEvent(customEvent) - } - }, - [map], - ) + 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() + e.originalEvent.stopPropagation() + e.preventDefault() - const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] }) + const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] }) - if (!features || features.length === 0) return + 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 + 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], - ) + 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 + setFocusedDistrictId(null) // Clear the focus when popup is closed - // Cancel rotation animation when closing popup - if (rotationAnimationRef.current) { - cancelAnimationFrame(rotationAnimationRef.current) - rotationAnimationRef.current = null - } + // Cancel rotation animation when closing popup + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } - bearingRef.current = 0 + 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 - }) + // 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]) + // 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 + 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 - } + 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}`, - }) + map.getMap().addSource("districts", { + type: "vector", + url: `mapbox://${tilesetId}`, + }) - const fillColorExpression: any = [ + 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"], [ @@ -478,717 +714,294 @@ export default function DistrictLayer({ 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) - } + ] as any) } - } catch (error) { - console.error("Error adding district layers:", error) } + } catch (error) { + console.error("Error adding district layers:", error) } + } - if (map.isStyleLoaded()) { - onStyleLoad() - } else { - map.once("style.load", onStyleLoad) - } + 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]) + 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] - } + 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, - ] - }) + 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 + 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) - } + map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression) + } - if (map.getMap().getLayer("district-extrusion")) { - map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""]) + 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", - ]) + 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() + 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 + 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, - ]) + 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) - } - } + if (progress < 1) { + requestAnimationFrame(animateHeight) + } + } - requestAnimationFrame(animateHeight) - } else { - const startHeight = 800 - const targetHeight = 0 - const duration = 500 - const startTime = performance.now() + 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 + 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, - ]) + map + .getMap() + .setPaintProperty("district-extrusion", "fill-extrusion-height", [ + "case", + ["has", "kode_kec"], + ["match", ["get", "kode_kec"], "", currentHeight, 0], + 0, + ]) - if (progress < 1) { - requestAnimationFrame(animateHeightDown) - } - } + if (progress < 1) { + requestAnimationFrame(animateHeightDown) + } + } - requestAnimationFrame(animateHeightDown) - } - } - } catch (error) { - console.error("Error updating district layer:", error) - } - }, [map, crimes, crimeDataByDistrict, focusedDistrictId]) + 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 { - // 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") - } + try { + const allIncidents = crimes.flatMap((crime) => { + if (!crime.crime_incidents) return [] - // 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") - } + let filteredIncidents = crime.crime_incidents - // Restore detailed incidents when timelapse stops - const allIncidents = crimes.flatMap((crime) => { - if (!crime.crime_incidents) return [] + if (filterCategory !== "all") { + filteredIncidents = crime.crime_incidents.filter( + (incident) => incident.crime_categories && incident.crime_categories.name === filterCategory, + ) + } - let filteredIncidents = crime.crime_incidents + return filteredIncidents + .map((incident) => { + if (!incident.locations) { + console.warn("Missing location for incident:", incident.id) + return null + } - 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]) - - // Add a new effect to update district colors even during timelapse - useEffect(() => { - if (!map || !map.getMap().getSource("districts")) 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 - - // Make district fills more prominent during timelapse - map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression) - map.getMap().setPaintProperty("district-fill", "fill-opacity", - isTimelapsePlaying ? 0.85 : 0.6) // Increase opacity during timelapse - } - } catch (error) { - console.error("Error updating district fill:", error) - } - }, [map, crimeDataByDistrict, focusedDistrictId, isTimelapsePlaying]) + 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) + const districtCrime = crimes.find((crime) => crime.district_id === districtId) - if (districtCrime) { - const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() + if (districtCrime) { + const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() - let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum) + 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] - } + 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) + 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)) + 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) + geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0] } - 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 (!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, } - if (!rotationAnimationRef.current && persistentFocusedDistrictRef.current) { - const startRotation = () => { - const rotationSpeed = 0.05 + selectedDistrictRef.current = updatedDistrict - 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() + setSelectedDistrict((prevDistrict) => { + if ( + prevDistrict?.id === updatedDistrict.id && + prevDistrict?.selectedYear === updatedDistrict.selectedYear && + prevDistrict?.selectedMonth === updatedDistrict.selectedMonth + ) { + return prevDistrict + } + return updatedDistrict + }) } - - 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]) + } + }, [crimes, filterCategory, year, month]) useEffect(() => { return () => { @@ -1217,4 +1030,3 @@ export default function DistrictLayer({ ) } - diff --git a/sigap-website/app/_components/map/layers/map-layer-manager.tsx b/sigap-website/app/_components/map/layers/map-layer-manager.tsx new file mode 100644 index 0000000..294d218 --- /dev/null +++ b/sigap-website/app/_components/map/layers/map-layer-manager.tsx @@ -0,0 +1,226 @@ +// "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(null) +// const [isStyleLoaded, setIsStyleLoaded] = useState(false) +// const [beforeId, setBeforeId] = useState(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, +// ) + +// // 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) && ( +// <> +// + +// + +// +// +// ) +// } +// +// ); +// }