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 */}