diff --git a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx index 50bab93..2025b7d 100644 --- a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx @@ -11,9 +11,9 @@ import { IconBubble, IconChartBubble } from "@tabler/icons-react" const crimeTooltips = [ { id: "incidents" as ITooltips, icon: , label: "All Incidents" }, { id: "heatmap" as ITooltips, icon: , label: "Crime Heatmap" }, + { id: "clusters" as ITooltips, icon: , label: "Clustered Incidents" }, { id: "units" as ITooltips, icon: , label: "Units" }, { id: "patrol" as ITooltips, icon: , label: "Patrol Areas" }, - { id: "clusters" as ITooltips, icon: , label: "Clusters" }, { id: "timeline" as ITooltips, icon: , label: "Time Analysis" }, ] diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 3e8bdc2..4ab7e72 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -2,7 +2,6 @@ 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-old" import MapView from "./map" import { Button } from "@/app/_components/ui/button" import { AlertCircle } from "lucide-react" @@ -23,6 +22,7 @@ import CrimeSidebar from "./controls/left/sidebar/map-sidebar" import Tooltips from "./controls/top/tooltips" import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map" import Layers from "./layers/layers" +import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old" // Updated CrimeIncident type to match the structure in crime_incidents interface ICrimeIncident { @@ -360,22 +360,25 @@ export default function CrimeMap() { !sidebarCollapsed && isFullscreen && "ml-[400px]" )}> - {/* District Layer with crime data - don't pass onClick if we want internal popup */} + {/* Replace the DistrictLayer with the new Layers component */} + {/* */} - - {/* Popup for selected incident */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( <> diff --git a/sigap-website/app/_components/map/layers/cluster-layer.tsx b/sigap-website/app/_components/map/layers/cluster-layer.tsx index a268d75..daebc58 100644 --- a/sigap-website/app/_components/map/layers/cluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/cluster-layer.tsx @@ -7,13 +7,20 @@ import type { GeoJSON } from "geojson" import { IClusterLayerProps } from "@/app/_utils/types/map" import { extractCrimeIncidents } from "@/app/_utils/map" +interface ExtendedClusterLayerProps extends IClusterLayerProps { + clusteringEnabled?: boolean; + showClusters?: boolean; +} + export default function ClusterLayer({ visible = true, map, crimes = [], filterCategory = "all", focusedDistrictId, -}: IClusterLayerProps) { + clusteringEnabled = false, + showClusters = false, +}: ExtendedClusterLayerProps) { const handleClusterClick = useCallback( (e: any) => { if (!map) return @@ -92,7 +99,7 @@ export default function ClusterLayer({ type: "FeatureCollection", features: allIncidents as GeoJSON.Feature[], }, - cluster: true, + cluster: clusteringEnabled, clusterMaxZoom: 14, clusterRadius: 50, }) @@ -110,7 +117,7 @@ export default function ClusterLayer({ "circle-opacity": 0.75, }, layout: { - visibility: focusedDistrictId ? "none" : "visible", + visibility: showClusters && !focusedDistrictId ? "visible" : "none", }, }, firstSymbolId, @@ -127,7 +134,7 @@ export default function ClusterLayer({ "text-field": "{point_count_abbreviated}", "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], "text-size": 12, - visibility: focusedDistrictId ? "none" : "visible", + visibility: showClusters && !focusedDistrictId ? "visible" : "none", }, paint: { "text-color": "#ffffff", @@ -147,12 +154,80 @@ export default function ClusterLayer({ map.off("click", "clusters", handleClusterClick) map.on("click", "clusters", handleClusterClick) } else { - // Update visibility based on focused district + // Update source clustering option + try { + // We need to recreate the source if we're changing the clustering option + const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource; + const data = (currentSource as any)._data; // Get current data + + // If clustering state has changed, recreate the source + const existingClusterState = (currentSource as any).options?.cluster; + if (existingClusterState !== clusteringEnabled) { + // Remove existing layers that use this source + if (map.getLayer("clusters")) map.removeLayer("clusters"); + if (map.getLayer("cluster-count")) map.removeLayer("cluster-count"); + if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point"); + + // Remove and recreate source with new clustering setting + map.removeSource("crime-incidents"); + + map.addSource("crime-incidents", { + type: "geojson", + data: data, + cluster: clusteringEnabled, + clusterMaxZoom: 14, + clusterRadius: 50, + }); + + // Re-add the layers + if (!map.getLayer("clusters")) { + map.addLayer( + { + id: "clusters", + type: "circle", + source: "crime-incidents", + filter: ["has", "point_count"], + paint: { + "circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"], + "circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40], + "circle-opacity": 0.75, + }, + layout: { + visibility: showClusters && !focusedDistrictId ? "visible" : "none", + }, + }, + firstSymbolId, + ); + } + + if (!map.getLayer("cluster-count")) { + map.addLayer({ + id: "cluster-count", + type: "symbol", + source: "crime-incidents", + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + visibility: showClusters && !focusedDistrictId ? "visible" : "none", + }, + paint: { + "text-color": "#ffffff", + }, + }); + } + } + } catch (error) { + console.error("Error updating cluster source:", error); + } + + // Update visibility based on focused district and showClusters flag if (map.getLayer("clusters")) { - map.setLayoutProperty("clusters", "visibility", focusedDistrictId ? "none" : "visible") + map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") } if (map.getLayer("cluster-count")) { - map.setLayoutProperty("cluster-count", "visibility", focusedDistrictId ? "none" : "visible") + map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") } // Update the cluster click handler @@ -175,7 +250,7 @@ export default function ClusterLayer({ map.off("click", "clusters", handleClusterClick) } } - }, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick]) + }, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick, clusteringEnabled, showClusters]) // Update crime incidents data when filters change useEffect(() => { @@ -192,5 +267,17 @@ export default function ClusterLayer({ } }, [map, crimes, filterCategory]) + // Update visibility when showClusters changes + useEffect(() => { + if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return; + + try { + map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none"); + map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none"); + } catch (error) { + console.error("Error updating cluster visibility:", error); + } + }, [map, showClusters, focusedDistrictId]); + 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 index d332257..573b327 100644 --- a/sigap-website/app/_components/map/layers/district-layer-old.tsx +++ b/sigap-website/app/_components/map/layers/district-layer-old.tsx @@ -47,7 +47,7 @@ export interface DistrictFeature { // District layer props export interface DistrictLayerProps { - visible?: boolean + visible?: boolean // New prop to control visibility onClick?: (feature: DistrictFeature) => void year: string month: string diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index 50a5299..a7000f3 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -1,5 +1,6 @@ "use client" +import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map" import { createFillColorExpression, processDistrictFeature } from "@/app/_utils/map" import { IDistrictLayerProps } from "@/app/_utils/types/map" import { useEffect } from "react" @@ -41,8 +42,9 @@ export default function DistrictFillLineLayer({ // Reset pitch and bearing with animation map.easeTo({ - pitch: 0, - bearing: 0, + zoom: BASE_ZOOM, + pitch: BASE_PITCH, + bearing: BASE_BEARING, duration: 1500, easing: (t) => t * (2 - t), // easeOutQuad }) @@ -75,7 +77,7 @@ export default function DistrictFillLineLayer({ // Fly to the new district map.flyTo({ center: [district.longitude, district.latitude], - zoom: 14.5, + zoom: 12.5, pitch: 75, bearing: 0, duration: 1500, @@ -111,7 +113,7 @@ export default function DistrictFillLineLayer({ // Animate to a pitched view focused on the district map.flyTo({ center: [district.longitude, district.latitude], - zoom: 14.5, + zoom: 12.5, pitch: 75, bearing: 0, duration: 1500, diff --git a/sigap-website/app/_components/map/layers/heatmap-layer.tsx b/sigap-website/app/_components/map/layers/heatmap-layer.tsx new file mode 100644 index 0000000..cfad38f --- /dev/null +++ b/sigap-website/app/_components/map/layers/heatmap-layer.tsx @@ -0,0 +1,89 @@ +import { Layer, Source } from "react-map-gl/mapbox"; +import { useMemo } from "react"; +import { ICrimes } from "@/app/_utils/types/crimes"; + +interface HeatmapLayerProps { + crimes: ICrimes[]; + year: string; + month: string; + filterCategory: string | "all"; + visible?: boolean; +} + +export default function HeatmapLayer({ crimes, visible = true }: HeatmapLayerProps) { + // Convert crime data to GeoJSON format for the heatmap + const heatmapData = useMemo(() => { + const features = crimes.flatMap(crime => + crime.crime_incidents + .filter(incident => incident.locations?.latitude && incident.locations?.longitude) + .map(incident => ({ + type: "Feature" as const, + properties: { + id: incident.id, + category: incident.crime_categories?.name || "Unknown", + intensity: 1, // Base intensity value + }, + geometry: { + type: "Point" as const, + coordinates: [incident.locations!.longitude, incident.locations!.latitude], + }, + })) + ); + + return { + type: "FeatureCollection" as const, + features, + }; + }, [crimes]); + + if (!visible) return null; + + return ( + + + + ); +} diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index 92cb21b..c66163a 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -6,15 +6,16 @@ import { BASE_BEARING, BASE_PITCH, BASE_ZOOM, MAPBOX_TILESET_ID } from "@/app/_u import DistrictPopup from "../pop-up/district-popup" import DistrictExtrusionLayer from "./district-extrusion-layer" import ClusterLayer from "./cluster-layer" - +import HeatmapLayer from "./heatmap-layer" +import DistrictLayer from "./district-layer-old" import type { ICrimes } from "@/app/_utils/types/crimes" import { IDistrictFeature } from "@/app/_utils/types/map" import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_utils/map" -import DistrictFillLineLayer from "./district-layer" import UnclusteredPointLayer from "./uncluster-layer" import FlyToHandler from "../fly-to" import { toast } from "sonner" +import { ITooltips } from "../controls/top/tooltips" // District layer props export interface IDistrictLayerProps { @@ -27,15 +28,25 @@ export interface IDistrictLayerProps { tilesetId?: string } +interface LayersProps { + visible?: boolean; + crimes: ICrimes[]; + year: string; + month: string; + filterCategory: string | "all"; + activeControl: ITooltips; + tilesetId?: string; +} + export default function Layers({ visible = true, - onClick, + crimes, year, month, - filterCategory = "all", - crimes = [], + filterCategory, + activeControl, tilesetId = MAPBOX_TILESET_ID, -}: IDistrictLayerProps) { +}: LayersProps) { const { current: map } = useMap() if (!map) { @@ -76,17 +87,6 @@ export default function Layers({ }; }, [mapboxMap]); - // Handle district selection - const handleDistrictClick = (district: IDistrictFeature) => { - selectedDistrictRef.current = district - - if (onClick) { - onClick(district) - } else { - setSelectedDistrict(district) - } - } - // Handle popup close const handleCloseDistrictPopup = () => { console.log("Closing district popup") @@ -204,22 +204,32 @@ export default function Layers({ if (!visible) return null + // Determine which layers should be visible based on the active control + const showDistrictLayer = activeControl === "incidents"; + const showHeatmapLayer = activeControl === "heatmap"; + const showClustersLayer = activeControl === "clusters"; + return ( <> - + {/* Heatmap Layer */} + + + {/* District base layer is always needed */} + {/* Cluster Layer - only enable clustering and make visible when the clusters control is active */} + {/* Unclustered Points Layer - hide when in cluster mode */}