261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useCallback, useRef, useState } from "react"
|
|
import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
|
|
interface HistoricalIncidentsLayerProps {
|
|
visible?: boolean
|
|
map: any
|
|
crimes?: ICrimes[]
|
|
filterCategory?: string | "all"
|
|
focusedDistrictId?: string | null
|
|
}
|
|
|
|
export default function HistoricalIncidentsLayer({
|
|
visible = false,
|
|
map,
|
|
crimes = [],
|
|
filterCategory = "all",
|
|
focusedDistrictId,
|
|
}: HistoricalIncidentsLayerProps) {
|
|
const isInteractingWithMarker = useRef(false);
|
|
const currentYear = new Date().getFullYear();
|
|
const startYear = 2020;
|
|
const [yearColors, setYearColors] = useState<Record<number, string>>({});
|
|
|
|
// Generate colors for each year from 2020 to current year
|
|
useEffect(() => {
|
|
const colors: Record<number, string> = {};
|
|
const yearCount = currentYear - startYear + 1;
|
|
|
|
for (let i = 0; i < yearCount; i++) {
|
|
const year = startYear + i;
|
|
// Generate a color gradient from red (2020) to blue (current year)
|
|
const red = Math.floor(255 - (i * 255 / yearCount));
|
|
const blue = Math.floor(i * 255 / yearCount);
|
|
colors[year] = `rgb(${red}, 70, ${blue})`;
|
|
}
|
|
|
|
setYearColors(colors);
|
|
}, [currentYear]);
|
|
|
|
const handleIncidentClick = useCallback(
|
|
(e: any) => {
|
|
if (!map) return;
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: ["historical-incidents"] });
|
|
if (!features || features.length === 0) return;
|
|
|
|
isInteractingWithMarker.current = true;
|
|
|
|
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(incident.properties.timestamp || Date.now()),
|
|
year: incident.properties.year,
|
|
};
|
|
|
|
console.log("Historical incident clicked:", incidentDetails);
|
|
|
|
// Ensure markers stay visible when clicking on them
|
|
if (map.getLayer("historical-incidents")) {
|
|
map.setLayoutProperty("historical-incidents", "visibility", "visible");
|
|
}
|
|
|
|
// First fly to the incident location
|
|
map.flyTo({
|
|
center: [incidentDetails.longitude, incidentDetails.latitude],
|
|
zoom: 15,
|
|
bearing: 0,
|
|
pitch: 45,
|
|
duration: 2000,
|
|
});
|
|
|
|
// Then dispatch the incident_click event to show the popup
|
|
const customEvent = new CustomEvent("incident_click", {
|
|
detail: incidentDetails,
|
|
bubbles: true,
|
|
});
|
|
|
|
map.getCanvas().dispatchEvent(customEvent);
|
|
document.dispatchEvent(customEvent);
|
|
|
|
// Reset the flag after a delay to allow the event to process
|
|
setTimeout(() => {
|
|
isInteractingWithMarker.current = false;
|
|
}, 5000);
|
|
},
|
|
[map]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!map || !visible) return;
|
|
|
|
console.log("Setting up historical incidents layer");
|
|
|
|
// Filter incidents from 2020 to current year
|
|
const historicalData = {
|
|
type: "FeatureCollection" as const,
|
|
features: crimes.flatMap((crime) =>
|
|
crime.crime_incidents
|
|
.filter(
|
|
(incident) => {
|
|
const incidentYear = incident.timestamp ? new Date(incident.timestamp).getFullYear() : null;
|
|
return (
|
|
(filterCategory === "all" || incident.crime_categories.name === filterCategory) &&
|
|
incident.locations &&
|
|
typeof incident.locations.longitude === "number" &&
|
|
typeof incident.locations.latitude === "number" &&
|
|
incidentYear !== null &&
|
|
incidentYear >= startYear &&
|
|
incidentYear <= currentYear
|
|
);
|
|
}
|
|
)
|
|
.map((incident) => {
|
|
const incidentYear = incident.timestamp ? new Date(incident.timestamp).getFullYear() : currentYear;
|
|
return {
|
|
type: "Feature" as const,
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [incident.locations.longitude, incident.locations.latitude],
|
|
},
|
|
properties: {
|
|
id: incident.id,
|
|
district: crime.districts.name,
|
|
district_id: crime.district_id,
|
|
category: incident.crime_categories.name,
|
|
incidentType: incident.crime_categories.type || "",
|
|
description: incident.description,
|
|
status: incident.status || "",
|
|
timestamp: incident.timestamp ? incident.timestamp.toString() : "",
|
|
year: incidentYear,
|
|
},
|
|
};
|
|
})
|
|
),
|
|
};
|
|
|
|
console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`);
|
|
|
|
const setupLayerAndSource = () => {
|
|
try {
|
|
// Check if source exists and update it
|
|
if (map.getSource("historical-incidents-source")) {
|
|
(map.getSource("historical-incidents-source") as any).setData(historicalData);
|
|
} else {
|
|
// If not, add source
|
|
map.addSource("historical-incidents-source", {
|
|
type: "geojson",
|
|
data: historicalData,
|
|
// No clustering configuration
|
|
});
|
|
}
|
|
|
|
// Find first symbol layer for proper layering
|
|
const layers = map.getStyle().layers;
|
|
let firstSymbolId: string | undefined;
|
|
for (const layer of layers) {
|
|
if (layer.type === "symbol") {
|
|
firstSymbolId = layer.id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Style for year-based coloring
|
|
const circleColorExpression: any[] = [
|
|
"match",
|
|
["get", "year"],
|
|
];
|
|
|
|
// Add entries for each year and its color
|
|
Object.entries(yearColors).forEach(([year, color]) => {
|
|
circleColorExpression.push(parseInt(year), color);
|
|
});
|
|
|
|
// Default color for unknown years
|
|
circleColorExpression.push("#888888");
|
|
|
|
// Check if layer exists already
|
|
if (!map.getLayer("historical-incidents")) {
|
|
map.addLayer({
|
|
id: "historical-incidents",
|
|
type: "circle",
|
|
source: "historical-incidents-source",
|
|
paint: {
|
|
"circle-color": circleColorExpression,
|
|
"circle-radius": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
7, 2, // Smaller circles at lower zoom levels
|
|
12, 4,
|
|
15, 6, // Smaller maximum size
|
|
],
|
|
"circle-stroke-width": 1,
|
|
"circle-stroke-color": "#ffffff",
|
|
"circle-opacity": 0.8,
|
|
},
|
|
layout: {
|
|
visibility: visible ? "visible" : "none",
|
|
}
|
|
}, firstSymbolId);
|
|
|
|
// Add mouse events
|
|
map.on("mouseenter", "historical-incidents", () => {
|
|
map.getCanvas().style.cursor = "pointer";
|
|
});
|
|
|
|
map.on("mouseleave", "historical-incidents", () => {
|
|
map.getCanvas().style.cursor = "";
|
|
});
|
|
} else {
|
|
// Update existing layer visibility
|
|
map.setLayoutProperty("historical-incidents", "visibility", visible ? "visible" : "none");
|
|
}
|
|
|
|
// Ensure click handler is properly registered
|
|
map.off("click", "historical-incidents", handleIncidentClick);
|
|
map.on("click", "historical-incidents", handleIncidentClick);
|
|
|
|
} catch (error) {
|
|
console.error("Error setting up historical incidents layer:", error);
|
|
}
|
|
};
|
|
|
|
// Check if style is loaded and set up layer accordingly
|
|
if (map.isStyleLoaded()) {
|
|
setupLayerAndSource();
|
|
} else {
|
|
map.once("style.load", setupLayerAndSource);
|
|
|
|
// Fallback
|
|
setTimeout(() => {
|
|
if (map.isStyleLoaded()) {
|
|
setupLayerAndSource();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
return () => {
|
|
if (map) {
|
|
map.off("click", "historical-incidents", handleIncidentClick);
|
|
}
|
|
};
|
|
}, [map, visible, crimes, filterCategory, handleIncidentClick, currentYear, yearColors]);
|
|
|
|
return null;
|
|
}
|