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

639 lines
21 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMap } from "react-map-gl/mapbox";
import {
BASE_BEARING,
BASE_DURATION,
BASE_PITCH,
BASE_ZOOM,
MAPBOX_TILESET_ID,
PITCH_3D,
ZOOM_3D,
} from "@/app/_utils/const/map";
import DistrictPopup from "../pop-up/district-popup";
import DistrictExtrusionLayer from "./district-extrusion-layer";
import ClusterLayer from "./cluster-layer";
import HeatmapLayer from "./heatmap-layer";
import TimelineLayer from "./timeline-layer";
import type { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
import type { ICrimeSourceTypes, IDistrictFeature } from "@/app/_utils/types/map";
import {
createFillColorExpression,
getCrimeRateColor,
processCrimeDataByDistrict,
} from "@/app/_utils/map/common";
import { toast } from "sonner";
import type { ITooltipsControl } from "../controls/top/tooltips";
import type { IUnits } from "@/app/_utils/types/units";
import UnitsLayer from "./units-layer";
import DistrictFillLineLayer from "./district-layer";
import TimezoneLayer from "./timezone";
import FaultLinesLayer from "./fault-lines";
import RecentIncidentsLayer from "./recent-incidents-layer";
import IncidentPopup from "../pop-up/incident-popup";
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
import AllIncidentsLayer from "./all-incidents-layer";
// Interface for crime incident
interface ICrimeIncident {
id: string;
district?: string;
category?: string;
type_category?: string | null;
description?: string;
status: string;
address?: string | null;
timestamp?: Date;
latitude?: number;
longitude?: number;
}
// District layer props
export interface IDistrictLayerProps {
visible?: boolean;
onClick?: (feature: IDistrictFeature) => void;
onDistrictClick?: (feature: IDistrictFeature) => void;
map?: any;
year: string;
month: string;
filterCategory: string | "all";
crimes: ICrimes[];
units?: IUnits[];
tilesetId?: string;
focusedDistrictId?: string | null;
setFocusedDistrictId?: (id: string | null) => void;
crimeDataByDistrict?: Record<string, any>;
showFill?: boolean;
activeControl?: ITooltipsControl;
}
interface LayersProps {
visible?: boolean;
crimes: ICrimes[];
units?: IUnits[];
recentIncidents: IIncidentLogs[];
year: string;
month: string;
filterCategory: string | "all";
activeControl: ITooltipsControl;
tilesetId?: string;
useAllData?: boolean;
showEWS?: boolean;
sourceType?: ICrimeSourceTypes;
}
export default function Layers({
visible = true,
crimes,
recentIncidents,
units,
year,
month,
filterCategory,
activeControl,
tilesetId = MAPBOX_TILESET_ID,
useAllData = false,
showEWS = true,
sourceType = "cbt",
}: LayersProps) {
const animationRef = useRef<number | null>(null);
const { current: map } = useMap();
if (!map) {
toast.error("Map not found");
return null;
}
const mapboxMap = map.getMap();
const [selectedDistrict, setSelectedDistrict] = useState<
IDistrictFeature | null
>(null);
const [selectedIncident, setSelectedIncident] = useState<
ICrimeIncident | null
>(null);
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(
null,
);
const selectedDistrictRef = useRef<IDistrictFeature | null>(null);
// Track if we're currently interacting with a marker to prevent district selection
const isInteractingWithMarker = useRef<boolean>(false);
const crimeDataByDistrict = processCrimeDataByDistrict(crimes);
const handlePopupClose = useCallback(() => {
selectedDistrictRef.current = null;
setSelectedDistrict(null);
setSelectedIncident(null);
setFocusedDistrictId(null);
isInteractingWithMarker.current = false;
if (map) {
map.easeTo({
zoom: BASE_ZOOM,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
duration: BASE_DURATION,
easing: (t) => t * (2 - t),
});
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty(
"clusters",
"visibility",
"visible",
);
}
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty(
"unclustered-point",
"visibility",
"visible",
);
}
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(
null,
crimeDataByDistrict,
);
map.getMap().setPaintProperty(
"district-fill",
"fill-color",
fillColorExpression as any,
);
}
}
}, [map, crimeDataByDistrict]);
const animateExtrusionDown = () => {
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) {
return;
}
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
// Get the current height from the layer (default to 800 if not found)
let currentHeight = 800;
try {
const paint = map.getPaintProperty(
"district-extrusion",
"fill-extrusion-height",
);
if (Array.isArray(paint) && paint.length > 0) {
// Try to extract the current height from the expression
const idx = paint.findIndex((v) => v === focusedDistrictId);
if (idx !== -1 && typeof paint[idx + 1] === "number") {
currentHeight = paint[idx + 1];
}
}
} catch {
// fallback to default
}
const startHeight = currentHeight;
const targetHeight = 0;
const duration = 700;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = progress * (2 - progress);
const newHeight = startHeight +
(targetHeight - startHeight) * easedProgress;
try {
map.getMap().setPaintProperty(
"district-extrusion",
"fill-extrusion-height",
[
"case",
["has", "kode_kec"],
[
"match",
["get", "kode_kec"],
focusedDistrictId,
newHeight,
0,
],
0,
],
);
map.getMap().setPaintProperty(
"district-extrusion",
"fill-extrusion-color",
[
"case",
["has", "kode_kec"],
[
"match",
["get", "kode_kec"],
focusedDistrictId || "",
"transparent",
"transparent",
],
"transparent",
],
);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
animationRef.current = null;
}
} catch (error) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
}
};
animationRef.current = requestAnimationFrame(animate);
};
const handleCloseDistrictPopup = useCallback(() => {
animateExtrusionDown();
handlePopupClose();
}, [handlePopupClose, animateExtrusionDown]);
const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => {
if (isInteractingWithMarker.current) {
return;
}
setSelectedIncident(null);
setSelectedDistrict(feature);
selectedDistrictRef.current = feature;
setFocusedDistrictId(feature.id);
if (map && feature.longitude && feature.latitude) {
map.flyTo({
center: [feature.longitude, feature.latitude],
zoom: ZOOM_3D,
pitch: PITCH_3D,
bearing: BASE_BEARING,
duration: BASE_DURATION,
easing: (t) => t * (2 - t),
});
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty(
"clusters",
"visibility",
"none",
);
}
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty(
"unclustered-point",
"visibility",
"none",
);
}
}
},
[map],
);
useEffect(() => {
if (!mapboxMap) return;
const handleFlyToEvent = (e: Event) => {
const customEvent = e as CustomEvent;
if (!map || !customEvent.detail) return;
const { longitude, latitude, zoom, bearing, pitch, duration } =
customEvent.detail;
map.flyTo({
center: [longitude, latitude],
zoom: zoom || 15,
bearing: bearing || 0,
pitch: pitch || 45,
duration: duration || 2000,
});
};
mapboxMap.getCanvas().addEventListener(
"mapbox_fly_to",
handleFlyToEvent as EventListener,
);
return () => {
if (mapboxMap && mapboxMap.getCanvas()) {
mapboxMap.getCanvas().removeEventListener(
"mapbox_fly_to",
handleFlyToEvent as EventListener,
);
}
};
}, [mapboxMap, map]);
useEffect(() => {
if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id;
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: IDistrictFeature = {
...selectedDistrictRef.current,
number_of_crime:
crimeDataByDistrict[districtId]?.number_of_crime || 0,
level: crimeDataByDistrict[districtId]?.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, crimeDataByDistrict]);
const handleSetFocusedDistrictId = useCallback(
(id: string | null, isMarkerClick = false) => {
if (isMarkerClick) {
isInteractingWithMarker.current = true;
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 1000);
}
setFocusedDistrictId(id);
},
[],
);
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu";
const showUnitsLayer = activeControl === "units";
const showTimelineLayer = activeControl === "timeline";
const showRecentIncidents = activeControl === "recents";
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
const showDistrictFill = activeControl === "clusters";
const showIncidentMarkers = activeControl !== "heatmap" &&
activeControl !== "timeline" && sourceType !== "cbu";
const shouldShowExtrusion = focusedDistrictId !== null &&
!isInteractingWithMarker.current;
useEffect(() => {
if (!mapboxMap) return;
const recentLayerIds = [
"very-recent-incidents-pulse",
"recent-incidents-glow",
"recent-incidents",
];
const timelineLayerIds = ["timeline-markers-bg", "timeline-markers"];
const heatmapLayerIds = ["heatmap-layer"];
const unitsLayerIds = [
"units-points",
"incidents-points",
"units-labels",
"units-connection-lines",
];
const clusterLayerIds = [
"clusters",
"cluster-count",
"crime-points",
"crime-count-labels",
];
const allIncidentsLayerIds = [
"all-incidents-pulse",
"all-incidents-circles",
"all-incidents",
];
if (activeControl !== "recents") {
manageLayerVisibility(mapboxMap, recentLayerIds, false);
}
if (activeControl !== "timeline") {
manageLayerVisibility(mapboxMap, timelineLayerIds, false);
}
if (activeControl !== "heatmap") {
manageLayerVisibility(mapboxMap, heatmapLayerIds, false);
}
if (activeControl !== "units") {
manageLayerVisibility(mapboxMap, unitsLayerIds, false);
}
if (activeControl !== "clusters") {
manageLayerVisibility(mapboxMap, clusterLayerIds, false);
}
if (activeControl !== "incidents") {
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
}
}, [activeControl, mapboxMap]);
return (
<>
<DistrictFillLineLayer
visible={true}
map={mapboxMap}
year={year}
month={month}
filterCategory={filterCategory}
crimes={crimes}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
setFocusedDistrictId={handleSetFocusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
showFill={showDistrictFill}
activeControl={activeControl}
onDistrictClick={handleDistrictClick}
/>
{shouldShowExtrusion && (
<DistrictExtrusionLayer
visible={true}
map={mapboxMap}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
/>
)}
<AllIncidentsLayer
visible={showAllIncidents}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
/>
<RecentIncidentsLayer
visible={showRecentIncidents}
map={mapboxMap}
incidents={recentIncidents}
/>
<HeatmapLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={showHeatmapLayer}
useAllData={useAllData}
enableInteractions={true}
setFocusedDistrictId={handleSetFocusedDistrictId}
/>
<TimelineLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={showTimelineLayer}
map={mapboxMap}
useAllData={useAllData}
/>
<UnitsLayer
crimes={crimes}
units={units}
filterCategory={filterCategory}
visible={showUnitsLayer}
map={mapboxMap}
/>
<ClusterLayer
visible={visible && activeControl === "clusters"}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
focusedDistrictId={focusedDistrictId}
clusteringEnabled={activeControl === "clusters"}
showClusters={activeControl === "clusters"}
sourceType={sourceType}
/>
{selectedDistrict && !selectedIncident &&
!isInteractingWithMarker.current && (
<DistrictPopup
longitude={selectedDistrict.longitude || 0}
latitude={selectedDistrict.latitude || 0}
onClose={handleCloseDistrictPopup}
district={selectedDistrict}
year={year}
month={month}
filterCategory={filterCategory}
/>
)}
<TimezoneLayer map={mapboxMap} />
<FaultLinesLayer map={mapboxMap} />
</>
);
}