Refactor map layers and pop-ups for improved incident handling and UI updates

- Removed unused CrimePopup component and replaced with IncidentPopup in layers.tsx
- Consolidated incident click handling logic in UnitsLayer and RecentIncidentsLayer
- Updated UnitsLayer to fetch nearest units and display them in the IncidentPopup
- Enhanced UI for displaying nearby police units with loading states and better formatting
- Cleaned up console logs for production readiness
- Adjusted map flyTo parameters for better user experience
- Added wave circle animations for incident markers
This commit is contained in:
vergiLgood1 2025-05-14 16:14:40 +07:00
parent f4b6d19eb2
commit 58f033d0e4
13 changed files with 547 additions and 615 deletions

View File

@ -1,6 +1,6 @@
import { IUnits } from '@/app/_utils/types/units';
import { useQuery } from '@tanstack/react-query';
import { getUnits } from '../action';
import { getNearestUnits, getUnits, INearestUnits } from '../action';
export const useGetUnitsQuery = () => {
return useQuery<IUnits[]>({
@ -8,3 +8,10 @@ export const useGetUnitsQuery = () => {
queryFn: () => getUnits(),
});
};
export const useGetNearestUnitsQuery = (lat: number, lon: number, max_results?: number) => {
return useQuery<INearestUnits[]>({
queryKey: ['nearest-units', lat, lon],
queryFn: () => getNearestUnits(lat, lon, max_results),
});
}

View File

@ -1,11 +1,25 @@
'use server';
import { createClient } from '@/app/_utils/supabase/client';
import { IUnits } from '@/app/_utils/types/units';
import { getInjection } from '@/di/container';
import db from '@/prisma/db';
import { AuthenticationError } from '@/src/entities/errors/auth';
import { InputParseError } from '@/src/entities/errors/common';
export interface INearestUnits {
code_unit: string;
name: string;
type: string;
address: string;
district_id: string;
lat_unit: number;
lon_unit: number;
distance_meters: number;
}
const supabase = createClient();
export async function getUnits(): Promise<IUnits[]> {
const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(
@ -63,3 +77,40 @@ export async function getUnits(): Promise<IUnits[]> {
}
);
}
export async function getNearestUnits(lat: number, lon: number, max_results: number = 5): Promise<INearestUnits[]> {
const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(
'District Crime Data',
{ recordResponse: true },
async () => {
try {
const { data, error } = await supabase.rpc('nearby_units', {
lat,
lon,
max_results
}).select();
if (error) {
console.error('Error fetching nearest units:', error);
return [];
}
if (!data) {
console.error('No data returned from RPC');
return [];
}
return data as INearestUnits[];
} catch (err) {
const crashReporterService = getInjection('ICrashReporterService');
crashReporterService.report(err);
throw new Error(
'An error happened. The developers have been notified. Please try again later.'
);
}
}
);
}

View File

@ -2,7 +2,6 @@
import { useState, useEffect } from 'react';
import mapboxgl from 'mapbox-gl';
import RecentCrimesLayer from '../layers/recent-crimes-layer';
import EWSAlertLayer from '../layers/ews-alert-layer';
import { IIncidentLog } from '@/app/_utils/types/ews';

View File

@ -231,23 +231,23 @@ export default function ClusterLayer({
const props = feature.properties
const coordinates = (feature.geometry as any).coordinates.slice()
if (props) {
if (props) {
const popupHTML = `
<div class="p-3">
<h3 class="font-bold">${props.district_name}</h3>
<div class="mt-2">
<p>Total Crimes: <b>${props.crime_count}</b></p>
<p>Crime Level: <b>${props.level}</b></p>
<p>Year: ${props.year} - Month: ${props.month}</p>
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
</div>
</div>
`
// if (props) {
// if (props) {
// const popupHTML = `
// <div class="p-3">
// <h3 class="font-bold">${props.district_name}</h3>
// <div class="mt-2">
// <p>Total Crimes: <b>${props.crime_count}</b></p>
// <p>Crime Level: <b>${props.level}</b></p>
// <p>Year: ${props.year} - Month: ${props.month}</p>
// ${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
// </div>
// </div>
// `
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
}
}
// new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
// }
// }
}
}
@ -436,21 +436,21 @@ export default function ClusterLayer({
const props = feature.properties
const coordinates = (feature.geometry as any).coordinates.slice()
if (props) {
const popupHTML = `
<div class="p-3">
<h3 class="font-bold">${props.district_name}</h3>
<div class="mt-2">
<p>Total Crimes: <b>${props.crime_count}</b></p>
<p>Crime Level: <b>${props.level}</b></p>
<p>Year: ${props.year} - Month: ${props.month}</p>
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
</div>
</div>
`
// if (props) {
// const popupHTML = `
// <div class="p-3">
// <h3 class="font-bold">${props.district_name}</h3>
// <div class="mt-2">
// <p>Total Crimes: <b>${props.crime_count}</b></p>
// <p>Crime Level: <b>${props.level}</b></p>
// <p>Year: ${props.year} - Month: ${props.month}</p>
// ${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
// </div>
// </div>
// `
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
}
// new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
// }
}
}
@ -532,24 +532,24 @@ export default function ClusterLayer({
const props = feature.properties;
const coordinates = (feature.geometry as any).coordinates.slice();
if (props) {
const popupHTML = `
<div class="p-3">
<h3 class="font-bold">${props.district_name}</h3>
<div class="mt-2">
<p>Total Crimes: <b>${props.crime_count}</b></p>
<p>Crime Level: <b>${props.level}</b></p>
<p>Year: ${props.year} - Month: ${props.month}</p>
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
</div>
</div>
`;
// if (props) {
// const popupHTML = `
// <div class="p-3">
// <h3 class="font-bold">${props.district_name}</h3>
// <div class="mt-2">
// <p>Total Crimes: <b>${props.crime_count}</b></p>
// <p>Crime Level: <b>${props.level}</b></p>
// <p>Year: ${props.year} - Month: ${props.month}</p>
// ${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
// </div>
// </div>
// `;
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(popupHTML)
.addTo(map);
}
// // new mapboxgl.Popup()
// // .setLngLat(coordinates)
// // .setHTML(popupHTML)
// // .addTo(map);
// }
}
};

View File

@ -10,7 +10,7 @@ import DigitalClock from '../markers/digital-clock';
import { Badge } from '@/app/_components/ui/badge';
import { Button } from '@/app/_components/ui/button';
import { IconCancel } from '@tabler/icons-react';
import { CustomAnimatedPopup } from '@/app/_utils/map/custom-animated-popup';
interface EWSAlertLayerProps {
map: mapboxgl.Map | null;
@ -245,32 +245,32 @@ export default function EWSAlertLayer({
);
// Create and attach the animated popup
const popup = new CustomAnimatedPopup({
closeOnClick: false,
openingAnimation: {
duration: 300,
easing: 'ease-out',
transform: 'scale'
},
closingAnimation: {
duration: 200,
easing: 'ease-in-out',
transform: 'scale'
}
}).setDOMContent(popupElement);
// const popup = new CustomAnimatedPopup({
// closeOnClick: false,
// openingAnimation: {
// duration: 300,
// easing: 'ease-out',
// transform: 'scale'
// },
// closingAnimation: {
// duration: 200,
// easing: 'ease-in-out',
// transform: 'scale'
// }
// }).setDOMContent(popupElement);
marker.setPopup(popup);
markersRef.current.set(incident.id, marker);
// marker.setPopup(popup);
// markersRef.current.set(incident.id, marker);
// Add wave circles around the incident point
if (map) {
popup.addWaveCircles(map, new mapboxgl.LngLat(longitude, latitude), {
color: incident.priority === 'high' ? '#ff0000' :
incident.priority === 'medium' ? '#ff9900' : '#0066ff',
maxRadius: 300,
count: 4
});
}
// // Add wave circles around the incident point
// if (map) {
// popup.addWaveCircles(map, new mapboxgl.LngLat(longitude, latitude), {
// color: incident.priority === 'high' ? '#ff0000' :
// incident.priority === 'medium' ? '#ff9900' : '#0066ff',
// maxRadius: 300,
// count: 4
// });
// }
// Fly to the incident if it's new
const isNewIncident = activeIncidents.length > 0 &&
@ -292,9 +292,9 @@ export default function EWSAlertLayer({
map.getContainer().dispatchEvent(flyToEvent);
// Auto-open popup for the newest incident
setTimeout(() => {
popup.addTo(map);
}, 2000);
// setTimeout(() => {
// popup.addTo(map);
// }, 2000);
}
});

View File

@ -67,7 +67,7 @@ export default function HistoricalIncidentsLayer({
year: incident.properties.year,
};
console.log("Historical incident clicked:", incidentDetails);
// console.log("Historical incident clicked:", incidentDetails);
// Ensure markers stay visible when clicking on them
if (map.getLayer("historical-incidents")) {
@ -103,7 +103,7 @@ export default function HistoricalIncidentsLayer({
useEffect(() => {
if (!map || !visible) return;
console.log("Setting up historical incidents layer");
// console.log("Setting up historical incidents layer");
// Filter incidents from 2020 to current year
const historicalData = {
@ -148,7 +148,7 @@ export default function HistoricalIncidentsLayer({
),
};
console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`);
// console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`);
const setupLayerAndSource = () => {
try {

View File

@ -19,7 +19,7 @@ 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 CrimePopup from "../pop-up/crime-popup"
import TimezoneLayer from "./timezone"
import FaultLinesLayer from "./fault-lines"
import EWSAlertLayer from "./ews-alert-layer"
@ -28,6 +28,7 @@ import PanicButtonDemo from "../controls/panic-button-demo"
import type { IIncidentLog } from "@/app/_utils/types/ews"
import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data"
import RecentIncidentsLayer from "./recent-incidents-layer"
import IncidentPopup from "../pop-up/incident-popup"
// Interface for crime incident
interface ICrimeIncident {
@ -247,24 +248,19 @@ export default function Layers({
}
const handleCloseDistrictPopup = useCallback(() => {
console.log("Closing district popup")
// console.log("Closing district popup")
animateExtrusionDown()
handlePopupClose()
}, [handlePopupClose, animateExtrusionDown])
const handleCloseIncidentPopup = useCallback(() => {
console.log("Closing incident popup")
handlePopupClose()
}, [handlePopupClose])
const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => {
console.log("District clicked:", feature)
// console.log("District clicked:", feature)
// If we're currently interacting with a marker, don't process district click
if (isInteractingWithMarker.current) {
console.log("Ignoring district click because we're interacting with a marker")
// console.log("Ignoring district click because we're interacting with a marker")
return
}
@ -325,139 +321,6 @@ export default function Layers({
}
}, [mapboxMap, map])
useEffect(() => {
if (!mapboxMap) return
const handleIncidentClickEvent = (e: Event) => {
const customEvent = e as CustomEvent
console.log("Received incident_click event in layers:", customEvent.detail)
if (!customEvent.detail) {
console.error("Empty incident click event data")
return
}
// Set the marker interaction flag to prevent district selection
isInteractingWithMarker.current = true
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
if (!incidentId) {
console.error("No incident ID found in event data:", customEvent.detail)
return
}
console.log("Looking for incident with ID:", incidentId)
let foundIncident: ICrimeIncident | undefined
if (
customEvent.detail.latitude !== undefined &&
customEvent.detail.longitude !== undefined &&
customEvent.detail.category !== undefined
) {
foundIncident = {
id: incidentId,
district: customEvent.detail.district,
category: customEvent.detail.category,
type_category: customEvent.detail.type,
description: customEvent.detail.description,
status: customEvent.detail.status || "Unknown",
timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined,
latitude: customEvent.detail.latitude,
longitude: customEvent.detail.longitude,
address: customEvent.detail.address,
}
} else {
for (const crime of crimes) {
for (const incident of crime.crime_incidents) {
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
console.log("Found matching incident:", incident)
foundIncident = {
id: incident.id,
district: crime.districts.name,
description: incident.description,
status: incident.status || "unknown",
timestamp: incident.timestamp,
category: incident.crime_categories.name,
type_category: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
}
break
}
}
if (foundIncident) break
}
}
if (!foundIncident) {
console.error("Could not find incident with ID:", incidentId)
isInteractingWithMarker.current = false
return
}
if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Found incident has invalid coordinates:", foundIncident)
isInteractingWithMarker.current = false
return
}
console.log("Setting selected incident:", foundIncident)
// Clear district selection when showing an incident
setSelectedDistrict(null)
selectedDistrictRef.current = null
setFocusedDistrictId(null)
setSelectedIncident(foundIncident)
// Reset the marker interaction flag after a delay
setTimeout(() => {
isInteractingWithMarker.current = false
}, 1000)
}
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
document.addEventListener("incident_click", handleIncidentClickEvent as EventListener)
console.log("Set up incident click event listener")
return () => {
console.log("Removing incident click event listener")
if (mapboxMap && mapboxMap.getCanvas()) {
mapboxMap.getCanvas().removeEventListener("incident_click", handleIncidentClickEvent as EventListener)
}
document.removeEventListener("incident_click", handleIncidentClickEvent as EventListener)
}
}, [mapboxMap, crimes, setFocusedDistrictId])
// Add a listener for unit clicks to set the marker interaction flag
useEffect(() => {
if (!mapboxMap) return
const handleUnitClickEvent = (e: Event) => {
// Set the marker interaction flag to prevent district selection
isInteractingWithMarker.current = true
// Reset the flag after a delay
setTimeout(() => {
isInteractingWithMarker.current = false
}, 1000)
}
mapboxMap.getCanvas().addEventListener("unit_click", handleUnitClickEvent as EventListener)
document.addEventListener("unit_click", handleUnitClickEvent as EventListener)
return () => {
if (mapboxMap && mapboxMap.getCanvas()) {
mapboxMap.getCanvas().removeEventListener("unit_click", handleUnitClickEvent as EventListener)
}
document.removeEventListener("unit_click", handleUnitClickEvent as EventListener)
}
}, [mapboxMap])
useEffect(() => {
if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id
@ -540,7 +403,7 @@ export default function Layers({
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => {
console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick)
// console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick)
// If this is from a marker click, set the marker interaction flag
if (isMarkerClick) {
@ -683,14 +546,7 @@ export default function Layers({
</div>
)}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<CrimePopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleCloseIncidentPopup}
incident={selectedIncident}
/>
)}
</>
)
}

View File

@ -51,7 +51,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
category: incident.properties.category,
}
console.log("Recent incident clicked:", incidentDetails)
// console.log("Recent incident clicked:", incidentDetails)
// Ensure markers stay visible
if (map.getLayer("recent-incidents")) {
@ -87,7 +87,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
useEffect(() => {
if (!map || !visible) return
console.log(`Setting up recent incidents layer with ${recentIncidents.length} incidents from the last 24 hours`)
// console.log(`Setting up recent incidents layer with ${recentIncidents.length} incidents from the last 24 hours`)
// Convert incidents to GeoJSON
const recentData = {

View File

@ -41,7 +41,7 @@ export default function UnclusteredPointLayer({
timestamp: new Date(incident.properties.timestamp || Date.now()),
}
console.log("Incident clicked:", incidentDetails)
// console.log("Incident clicked:", incidentDetails)
// Ensure markers stay visible when clicking on them
if (map.getLayer("unclustered-point")) {

View File

@ -8,6 +8,10 @@ import type mapboxgl from "mapbox-gl"
import { generateCategoryColorMap } from "@/app/_utils/colors"
import UnitPopup from "../pop-up/unit-popup"
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
import { INearestUnits } from "@/app/(pages)/(admin)/dashboard/crime-management/units/action"
import { useGetNearestUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries"
import IncidentPopup from "../pop-up/incident-popup"
interface UnitsLayerProps {
@ -39,6 +43,13 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
const [unitIncident, setUnitIncident] = useState<IDistrictIncidents[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [incidentCoords, setIncidentCoords] = useState<{ lat: number, lon: number } | null>(null)
const { data: nearestUnits, isLoading: isLoadingNearestUnits } = useGetNearestUnitsQuery(
incidentCoords?.lat ?? 0,
incidentCoords?.lon ?? 0,
5
)
// Use either provided units or loaded units
const unitsData = useMemo(() => {
return units.length > 0 ? units : loadedUnits || []
@ -64,18 +75,9 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Process units data to GeoJSON format
const unitsGeoJSON = useMemo(() => {
console.log("Units data being processed:", unitsData) // Debug log
return {
type: "FeatureCollection" as const,
features: unitsData.map((unit) => {
// Debug log for individual units
console.log("Processing unit:", unit.code_unit, unit.name, {
longitude: unit.longitude,
latitude: unit.latitude,
district: unit.district_name,
})
return {
type: "Feature" as const,
properties: {
@ -235,6 +237,7 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Find the unit in our data
const unit = unitsData.find((u) => u.code_unit === properties.id)
if (!unit) {
console.log("Unit not found in data:", properties.id)
return
@ -245,9 +248,11 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Find all incidents in the same district as the unit
const districtIncidents: IDistrictIncidents[] = []
crimes.forEach((crime) => {
// Check if this crime is in the same district as the unit
if (selectedUnit?.code_unit === unit.code_unit) {
console.log("Processing crime:", crime.district_id, unit.district_id) // Debug log
// Check if this crime is in the same district as the unit
if (crime.district_id === unit.district_id) {
crime.crime_incidents.forEach((incident) => {
if (incident.locations && typeof incident.locations.distance_to_unit !== "undefined") {
districtIncidents.push({
@ -265,7 +270,7 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Sort by distance (closest first)
districtIncidents.sort((a, b) => a.distance_meters - b.distance_meters)
console.log("Sorted district incidents:", districtIncidents)
// console.log("Sorted district incidents:", districtIncidents)
// Update the state with the distance results
setUnitIncident(districtIncidents)
@ -274,10 +279,10 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Fly to the unit location
map.flyTo({
center: [unit.longitude || 0, unit.latitude || 0],
zoom: 14,
zoom: 12.5,
pitch: 45,
bearing: 0,
duration: 2000,
bearing: BASE_BEARING,
duration: BASE_DURATION,
})
// Set the selected unit and query parameters
@ -339,10 +344,10 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Fly to the incident location
map.flyTo({
center: [longitude, latitude],
zoom: 15,
zoom: 16,
pitch: 45,
bearing: 0,
duration: 2000,
bearing: BASE_BEARING,
duration: BASE_DURATION,
})
// Create incident object from properties
@ -358,19 +363,23 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
latitude,
}
// Debug log
console.log("Incident clicked:", incident)
// Set the selected incident and query parameters
setSelectedIncident(incident)
setSelectedUnit(null) // Clear any selected unit
setSelectedEntityId(properties.id)
setIsUnitSelected(false)
setSelectedDistrictId(properties.district_id)
setIncidentCoords({ lat: latitude, lon: longitude })
// Highlight the connected lines for this incident
if (map.getLayer("units-connection-lines")) {
map.setFilter("units-connection-lines", ["==", ["get", "incident_id"], properties.id])
}
// Dispatch a custom event for other components to react to
// Dispatch a custom event for other components to react to
const customEvent = new CustomEvent("incident_click", {
detail: {
id: properties.id,
@ -428,10 +437,11 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
useEffect(() => {
if (!map || !visible) return
// Debug log to confirm map layers
console.log(
"Available map layers:",
map.getStyle().layers?.map((l) => l.id),
// Debug log untuk memeriksa keberadaan layer
console.log("Setting up event handlers, map layers:",
map.getStyle().layers?.filter(l =>
l.id === "units-points" || l.id === "incidents-points"
).map(l => l.id)
)
// Define event handlers that can be referenced for both adding and removing
@ -443,27 +453,51 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
map.getCanvas().style.cursor = ""
}
// Add click event for units-points layer
if (map.getLayer("units-points")) {
map.off("click", "units-points", unitClickHandler)
map.on("click", "units-points", unitClickHandler)
// Fungsi untuk setup event handler
const setupHandlers = () => {
// Add click event for units-points layer
if (map.getLayer("units-points")) {
map.off("click", "units-points", unitClickHandler)
map.on("click", "units-points", unitClickHandler)
map.on("mouseenter", "units-points", handleMouseEnter)
map.on("mouseleave", "units-points", handleMouseLeave)
console.log("✅ Unit points handler attached")
} else {
console.log("❌ units-points layer not found")
}
// Change cursor on hover
map.on("mouseenter", "units-points", handleMouseEnter)
map.on("mouseleave", "units-points", handleMouseLeave)
// Add click event for incidents-points layer
if (map.getLayer("incidents-points")) {
map.off("click", "incidents-points", incidentClickHandler)
map.on("click", "incidents-points", incidentClickHandler)
map.on("mouseenter", "incidents-points", handleMouseEnter)
map.on("mouseleave", "incidents-points", handleMouseLeave)
console.log("✅ Incident points handler attached")
} else {
console.log("❌ incidents-points layer not found")
}
}
// Add click event for incidents-points layer
if (map.getLayer("incidents-points")) {
map.off("click", "incidents-points", incidentClickHandler)
map.on("click", "incidents-points", incidentClickHandler)
// Setup handlers langsung
setupHandlers()
// Change cursor on hover
map.on("mouseenter", "incidents-points", handleMouseEnter)
map.on("mouseleave", "incidents-points", handleMouseLeave)
}
// Safety check: pastikan handler terpasang setelah layer mungkin dimuat
const checkLayersTimeout = setTimeout(() => {
setupHandlers()
}, 1000)
// Listen for style.load event to reattach handlers setelah perubahan style
map.on('style.load', setupHandlers)
map.on('sourcedata', (e) => {
if (e.sourceId === 'incidents-source' && e.isSourceLoaded && map.getLayer('incidents-points')) {
setupHandlers()
}
})
return () => {
clearTimeout(checkLayersTimeout)
map.off('style.load', setupHandlers)
if (map) {
if (map.getLayer("units-points")) {
map.off("click", "units-points", unitClickHandler)
@ -482,6 +516,7 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
// Reset map filters when popup is closed
const handleClosePopup = useCallback(() => {
console.log("Closing popup, clearing selected states")
setSelectedUnit(null)
setSelectedIncident(null)
setSelectedEntityId(undefined)
@ -490,6 +525,14 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
setIsLoading(false)
if (map && map.getLayer("units-connection-lines")) {
map.easeTo({
zoom: BASE_ZOOM,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
duration: BASE_DURATION,
easing: (t) => t * (2 - t),
})
map.setFilter("units-connection-lines", ["has", "unit_id"])
}
}, [map])
@ -501,6 +544,15 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
}
}, [visible, handleClosePopup])
// Debug untuk komponen render
useEffect(() => {
console.log("Render state:", {
selectedUnit: selectedUnit?.code_unit,
selectedIncident: selectedIncident?.id,
visible
})
}, [selectedUnit, selectedIncident, visible])
if (!visible) return null
return (
@ -599,6 +651,8 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
nearestUnit={nearestUnits}
isLoadingNearestUnit={isLoadingNearestUnits}
/>
)}
</>

View File

@ -25,7 +25,7 @@ interface IncidentPopupProps {
}
}
export default function IncidentPopup({ longitude, latitude, onClose, incident }: IncidentPopupProps) {
export default function CrimePopup({ longitude, latitude, onClose, incident }: IncidentPopupProps) {
const formatDate = (date?: Date) => {
if (!date) return "Unknown date"
return new Date(date).toLocaleDateString()
@ -178,28 +178,6 @@ export default function IncidentPopup({ longitude, latitude, onClose, incident }
</div>
</div>
</Card>
{/* Connection line */}
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full"
style={{
width: '2px',
height: '20px',
backgroundColor: 'red',
boxShadow: '0 0 4px rgba(0, 0, 0, 0.3)'
}}
/>
{/* Connection dot */}
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full"
style={{
width: '6px',
height: '6px',
backgroundColor: 'red',
borderRadius: '50%',
marginTop: '20px',
boxShadow: '0 0 4px rgba(0, 0, 0, 0.3)'
}}
/>
</div>
</Popup>
)

View File

@ -5,10 +5,11 @@ import { Badge } from "@/app/_components/ui/badge"
import { Card } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator"
import { Button } from "@/app/_components/ui/button"
import { MapPin, AlertTriangle, Calendar, Clock, Bookmark, Navigation, X, FileText } from "lucide-react"
import { MapPin, AlertTriangle, Calendar, Clock, Bookmark, Navigation, X, FileText, Shield } from "lucide-react"
import { IDistanceResult } from "@/app/_utils/types/crimes"
import { ScrollArea } from "@/app/_components/ui/scroll-area"
import { Skeleton } from "@/app/_components/ui/skeleton"
import { INearestUnits } from "@/app/(pages)/(admin)/dashboard/crime-management/units/action"
interface IncidentPopupProps {
longitude: number
@ -22,8 +23,8 @@ interface IncidentPopupProps {
district?: string
district_id?: string
}
distances?: IDistanceResult[]
isLoadingDistances?: boolean
nearestUnit?: INearestUnits[]
isLoadingNearestUnit?: boolean
}
export default function IncidentPopup({
@ -31,8 +32,8 @@ export default function IncidentPopup({
latitude,
onClose,
incident,
distances = [],
isLoadingDistances = false
nearestUnit = [],
isLoadingNearestUnit = false
}: IncidentPopupProps) {
const formatDate = (date?: Date | string) => {
@ -130,31 +131,39 @@ export default function IncidentPopup({
)}
</div>
{/* Distances to police units section */}
{/* NearestUnit to police nearestUnits section */}
<Separator className="my-3" />
<div>
<h4 className="text-sm font-medium mb-2">Nearby Police Units</h4>
<h4 className="text-sm font-medium mb-2 flex items-center">
<Shield className="h-4 w-4 mr-1.5 text-blue-600" />
Nearby Units
</h4>
{isLoadingDistances ? (
{isLoadingNearestUnit ? (
<div className="space-y-2">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
) : distances.length > 0 ? (
<ScrollArea className="h-[120px] rounded-md border p-2">
) : nearestUnit.length > 0 ? (
<ScrollArea className="h-[100px] rounded-md border p-2">
<div className="space-y-2">
{distances.map((item) => (
<div key={item.unit_code} className="flex justify-between items-center text-xs border-b pb-1">
{nearestUnit.map((unit) => (
<div key={unit.code_unit} className="flex justify-between items-center text-xs border-b pb-1">
<div>
<p className="font-medium">{item.unit_name || "Unknown Unit"}</p>
<p className="text-muted-foreground text-[10px]">
{item.unit_type || "Police Unit"}
<p className="font-medium">
{unit.name || "Unknown"}
<span className="ml-2 text-[10px] text-slate-500 font-normal">
({unit.type || "Unknown type"})
</span>
</p>
<p className="text-muted-foreground text-[10px] truncate" style={{ maxWidth: "160px" }}>
{unit.address || "No address"}
</p>
</div>
<Badge variant="outline" className="ml-2">
{formatDistance(item.distance_meters)}
<Badge variant="outline" className="ml-2 whitespace-nowrap text-blue-600">
{formatDistance(unit.distance_meters)}
</Badge>
</div>
))}
@ -162,7 +171,7 @@ export default function IncidentPopup({
</ScrollArea>
) : (
<p className="text-xs text-muted-foreground text-center p-2">
No police units data available
No nearby units found
</p>
)}
</div>
@ -176,28 +185,6 @@ export default function IncidentPopup({
</div>
</div>
</Card>
{/* Connection line */}
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full"
style={{
width: '2px',
height: '20px',
backgroundColor: 'red',
boxShadow: '0 0 4px rgba(0, 0, 0, 0.3)'
}}
/>
{/* Connection dot */}
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full"
style={{
width: '6px',
height: '6px',
backgroundColor: 'red',
borderRadius: '50%',
marginTop: '20px',
boxShadow: '0 0 4px rgba(0, 0, 0, 0.3)'
}}
/>
</div>
</Popup>
)

View File

@ -1,323 +1,323 @@
import mapboxgl from 'mapbox-gl';
// import mapboxgl from 'mapbox-gl';
interface AnimationOptions {
duration: number;
easing: string;
transform: string;
}
// interface AnimationOptions {
// duration: number;
// easing: string;
// transform: string;
// }
interface CustomPopupOptions extends mapboxgl.PopupOptions {
openingAnimation?: AnimationOptions;
closingAnimation?: AnimationOptions;
}
// interface CustomPopupOptions extends mapboxgl.PopupOptions {
// openingAnimation?: AnimationOptions;
// closingAnimation?: AnimationOptions;
// }
// Extend the native Mapbox Popup
export class CustomAnimatedPopup extends mapboxgl.Popup {
private openingAnimation: AnimationOptions;
private closingAnimation: AnimationOptions;
private animating = false;
// // Extend the native Mapbox Popup
// export class CustomAnimatedPopup extends mapboxgl.Popup {
// private openingAnimation: AnimationOptions;
// private closingAnimation: AnimationOptions;
// private animating = false;
constructor(options: CustomPopupOptions = {}) {
// Extract animation options and pass the rest to the parent class
const {
openingAnimation,
closingAnimation,
className,
...mapboxOptions
} = options;
// constructor(options: CustomPopupOptions = {}) {
// // Extract animation options and pass the rest to the parent class
// const {
// openingAnimation,
// closingAnimation,
// className,
// ...mapboxOptions
// } = options;
// Add our custom class to the className
const customClassName = `custom-animated-popup ${className || ''}`.trim();
// // Add our custom class to the className
// const customClassName = `custom-animated-popup ${className || ''}`.trim();
// Call the parent constructor
super({
...mapboxOptions,
className: customClassName,
});
// // Call the parent constructor
// super({
// ...mapboxOptions,
// className: customClassName,
// });
// Store animation options
this.openingAnimation = openingAnimation || {
duration: 300,
easing: 'ease-out',
transform: 'scale'
};
// // Store animation options
// this.openingAnimation = openingAnimation || {
// duration: 300,
// easing: 'ease-out',
// transform: 'scale'
// };
this.closingAnimation = closingAnimation || {
duration: 200,
easing: 'ease-in-out',
transform: 'scale'
};
// this.closingAnimation = closingAnimation || {
// duration: 200,
// easing: 'ease-in-out',
// transform: 'scale'
// };
// Override the parent's add method
const parentAdd = this.addTo;
this.addTo = (map: mapboxgl.Map) => {
// Call the parent method first
parentAdd.call(this, map);
// // Override the parent's add method
// const parentAdd = this.addTo;
// this.addTo = (map: mapboxgl.Map) => {
// // Call the parent method first
// parentAdd.call(this, map);
// Apply animation after a short delay to ensure the element is in the DOM
setTimeout(() => this.animateOpen(), 10);
// // Apply animation after a short delay to ensure the element is in the DOM
// setTimeout(() => this.animateOpen(), 10);
return this;
};
}
// return this;
// };
// }
// Override the remove method to add animation
remove(): this {
if (this.animating) {
return this;
}
// // Override the remove method to add animation
// remove(): this {
// if (this.animating) {
// return this;
// }
this.animateClose(() => {
super.remove();
});
// this.animateClose(() => {
// super.remove();
// });
return this;
}
// return this;
// }
// Animation methods
private animateOpen(): void {
const container = this._container;
if (!container) return;
// // Animation methods
// private animateOpen(): void {
// const container = this._container;
// if (!container) return;
// Apply initial state
container.style.opacity = '0';
container.style.transform = 'scale(0.8)';
container.style.transition = `
opacity ${this.openingAnimation.duration}ms ${this.openingAnimation.easing},
transform ${this.openingAnimation.duration}ms ${this.openingAnimation.easing}
`;
// // Apply initial state
// container.style.opacity = '0';
// container.style.transform = 'scale(0.8)';
// container.style.transition = `
// opacity ${this.openingAnimation.duration}ms ${this.openingAnimation.easing},
// transform ${this.openingAnimation.duration}ms ${this.openingAnimation.easing}
// `;
// Force reflow
void container.offsetHeight;
// // Force reflow
// void container.offsetHeight;
// Apply final state to trigger animation
container.style.opacity = '1';
container.style.transform = 'scale(1)';
}
// // Apply final state to trigger animation
// container.style.opacity = '1';
// container.style.transform = 'scale(1)';
// }
private animateClose(callback: () => void): void {
const container = this._container;
if (!container) {
callback();
return;
}
// private animateClose(callback: () => void): void {
// const container = this._container;
// if (!container) {
// callback();
// return;
// }
this.animating = true;
// this.animating = true;
// Setup transition
container.style.transition = `
opacity ${this.closingAnimation.duration}ms ${this.closingAnimation.easing},
transform ${this.closingAnimation.duration}ms ${this.closingAnimation.easing}
`;
// // Setup transition
// container.style.transition = `
// opacity ${this.closingAnimation.duration}ms ${this.closingAnimation.easing},
// transform ${this.closingAnimation.duration}ms ${this.closingAnimation.easing}
// `;
// Apply closing animation
container.style.opacity = '0';
container.style.transform = 'scale(0.8)';
// // Apply closing animation
// container.style.opacity = '0';
// container.style.transform = 'scale(0.8)';
// Execute callback after animation completes
setTimeout(() => {
this.animating = false;
callback();
}, this.closingAnimation.duration);
}
// // Execute callback after animation completes
// setTimeout(() => {
// this.animating = false;
// callback();
// }, this.closingAnimation.duration);
// }
// Add method to create expanding wave circles
addWaveCircles(map: mapboxgl.Map, lngLat: mapboxgl.LngLat, options: {
color?: string,
maxRadius?: number,
duration?: number,
count?: number,
showCenter?: boolean
} = {}): void {
const {
color = 'red',
maxRadius = 80, // Reduce max radius for less "over" effect
duration = 2000, // Faster animation
count = 2, // Fewer circles
showCenter = true
} = options;
// // Add method to create expanding wave circles
// addWaveCircles(map: mapboxgl.Map, lngLat: mapboxgl.LngLat, options: {
// color?: string,
// maxRadius?: number,
// duration?: number,
// count?: number,
// showCenter?: boolean
// } = {}): void {
// const {
// color = 'red',
// maxRadius = 80, // Reduce max radius for less "over" effect
// duration = 2000, // Faster animation
// count = 2, // Fewer circles
// showCenter = true
// } = options;
const sourceId = `wave-circles-${Math.random().toString(36).substring(2, 9)}`;
// const sourceId = `wave-circles-${Math.random().toString(36).substring(2, 9)}`;
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [lngLat.lng, lngLat.lat]
},
properties: {
radius: 0
}
}]
}
});
// if (!map.getSource(sourceId)) {
// map.addSource(sourceId, {
// type: 'geojson',
// data: {
// type: 'FeatureCollection',
// features: [{
// type: 'Feature',
// geometry: {
// type: 'Point',
// coordinates: [lngLat.lng, lngLat.lat]
// },
// properties: {
// radius: 0
// }
// }]
// }
// });
for (let i = 0; i < count; i++) {
const layerId = `${sourceId}-layer-${i}`;
const delay = i * (duration / count);
// for (let i = 0; i < count; i++) {
// const layerId = `${sourceId}-layer-${i}`;
// const delay = i * (duration / count);
map.addLayer({
id: layerId,
type: 'circle',
source: sourceId,
paint: {
'circle-radius': ['interpolate', ['linear'], ['get', 'radius'], 0, 0, 100, maxRadius],
'circle-color': 'transparent',
'circle-opacity': ['interpolate', ['linear'], ['get', 'radius'],
0, showCenter ? 0.15 : 0, // Lower opacity
100, 0
],
'circle-stroke-width': 1.5, // Thinner stroke
'circle-stroke-color': color
}
});
// map.addLayer({
// id: layerId,
// type: 'circle',
// source: sourceId,
// paint: {
// 'circle-radius': ['interpolate', ['linear'], ['get', 'radius'], 0, 0, 100, maxRadius],
// 'circle-color': 'transparent',
// 'circle-opacity': ['interpolate', ['linear'], ['get', 'radius'],
// 0, showCenter ? 0.15 : 0, // Lower opacity
// 100, 0
// ],
// 'circle-stroke-width': 1.5, // Thinner stroke
// 'circle-stroke-color': color
// }
// });
this.animateWaveCircle(map, sourceId, layerId, duration, delay);
}
}
}
// this.animateWaveCircle(map, sourceId, layerId, duration, delay);
// }
// }
// }
private animateWaveCircle(
map: mapboxgl.Map,
sourceId: string,
layerId: string,
duration: number,
delay: number
): void {
let start: number | null = null;
let animationId: number;
// private animateWaveCircle(
// map: mapboxgl.Map,
// sourceId: string,
// layerId: string,
// duration: number,
// delay: number
// ): void {
// let start: number | null = null;
// let animationId: number;
const animate = (timestamp: number) => {
if (!start) {
start = timestamp + delay;
}
// const animate = (timestamp: number) => {
// if (!start) {
// start = timestamp + delay;
// }
const progress = Math.max(0, timestamp - start);
const progressPercent = Math.min(progress / duration, 1);
// const progress = Math.max(0, timestamp - start);
// const progressPercent = Math.min(progress / duration, 1);
if (map.getSource(sourceId)) {
(map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: (map.getSource(sourceId) as any)._data.features[0].geometry.coordinates
},
properties: {
radius: progressPercent * 100
}
}]
});
}
// if (map.getSource(sourceId)) {
// (map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData({
// type: 'FeatureCollection',
// features: [{
// type: 'Feature',
// geometry: {
// type: 'Point',
// coordinates: (map.getSource(sourceId) as any)._data.features[0].geometry.coordinates
// },
// properties: {
// radius: progressPercent * 100
// }
// }]
// });
// }
if (progressPercent < 1) {
animationId = requestAnimationFrame(animate);
} else if (map.getLayer(layerId)) {
// Restart the animation for continuous effect
start = null;
animationId = requestAnimationFrame(animate);
}
};
// if (progressPercent < 1) {
// animationId = requestAnimationFrame(animate);
// } else if (map.getLayer(layerId)) {
// // Restart the animation for continuous effect
// start = null;
// animationId = requestAnimationFrame(animate);
// }
// };
// Start the animation after delay
setTimeout(() => {
animationId = requestAnimationFrame(animate);
}, delay);
// // Start the animation after delay
// setTimeout(() => {
// animationId = requestAnimationFrame(animate);
// }, delay);
// Clean up on popup close
this.once('close', () => {
cancelAnimationFrame(animationId);
if (map.getLayer(layerId)) {
map.removeLayer(layerId);
}
if (map.getSource(sourceId) && !map.getLayer(layerId)) {
map.removeSource(sourceId);
}
});
}
}
// // Clean up on popup close
// this.once('close', () => {
// cancelAnimationFrame(animationId);
// if (map.getLayer(layerId)) {
// map.removeLayer(layerId);
// }
// if (map.getSource(sourceId) && !map.getLayer(layerId)) {
// map.removeSource(sourceId);
// }
// });
// }
// }
// Add styles to document when in browser environment
if (typeof document !== 'undefined') {
// Add styles only if they don't exist yet
if (!document.getElementById('custom-animated-popup-styles')) {
const style = document.createElement('style');
style.id = 'custom-animated-popup-styles';
style.textContent = `
.custom-animated-popup {
will-change: transform, opacity;
}
.custom-animated-popup .mapboxgl-popup-content {
overflow: hidden;
}
// // Add styles to document when in browser environment
// if (typeof document !== 'undefined') {
// // Add styles only if they don't exist yet
// if (!document.getElementById('custom-animated-popup-styles')) {
// const style = document.createElement('style');
// style.id = 'custom-animated-popup-styles';
// style.textContent = `
// .custom-animated-popup {
// will-change: transform, opacity;
// }
// .custom-animated-popup .mapboxgl-popup-content {
// overflow: hidden;
// }
/* Marker styles with wave circles */
.marker-gempa {
position: relative;
width: 30px;
height: 30px;
cursor: pointer;
}
// /* Marker styles with wave circles */
// .marker-gempa {
// position: relative;
// width: 30px;
// height: 30px;
// cursor: pointer;
// }
.circles {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
// .circles {
// position: relative;
// width: 100%;
// height: 100%;
// display: flex;
// align-items: center;
// justify-content: center;
// }
.circle1, .circle2, .circle3 {
position: absolute;
border-radius: 50%;
border: 2px solid red;
width: 100%;
height: 100%;
opacity: 0;
animation: pulse 2s infinite;
}
// .circle1, .circle2, .circle3 {
// position: absolute;
// border-radius: 50%;
// border: 2px solid red;
// width: 100%;
// height: 100%;
// opacity: 0;
// animation: pulse 2s infinite;
// }
.circle2 {
animation-delay: 0.5s;
}
// .circle2 {
// animation-delay: 0.5s;
// }
.circle3 {
animation-delay: 1s;
}
// .circle3 {
// animation-delay: 1s;
// }
@keyframes pulse {
0% {
transform: scale(0.5);
opacity: 0;
}
50% {
opacity: 0.8;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
// @keyframes pulse {
// 0% {
// transform: scale(0.5);
// opacity: 0;
// }
// 50% {
// opacity: 0.8;
// }
// 100% {
// transform: scale(1.5);
// opacity: 0;
// }
// }
.blink {
animation: blink 1s infinite;
color: red;
font-size: 20px;
}
// .blink {
// animation: blink 1s infinite;
// color: red;
// font-size: 20px;
// }
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.3; }
100% { opacity: 1; }
}
`;
document.head.appendChild(style);
}
}
// @keyframes blink {
// 0% { opacity: 1; }
// 50% { opacity: 0.3; }
// 100% { opacity: 1; }
// }
// `;
// document.head.appendChild(style);
// }
// }