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 { IUnits } from '@/app/_utils/types/units';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getUnits } from '../action'; import { getNearestUnits, getUnits, INearestUnits } from '../action';
export const useGetUnitsQuery = () => { export const useGetUnitsQuery = () => {
return useQuery<IUnits[]>({ return useQuery<IUnits[]>({
@ -8,3 +8,10 @@ export const useGetUnitsQuery = () => {
queryFn: () => getUnits(), 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'; 'use server';
import { createClient } from '@/app/_utils/supabase/client';
import { IUnits } from '@/app/_utils/types/units'; import { IUnits } from '@/app/_utils/types/units';
import { getInjection } from '@/di/container'; import { getInjection } from '@/di/container';
import db from '@/prisma/db'; import db from '@/prisma/db';
import { AuthenticationError } from '@/src/entities/errors/auth'; import { AuthenticationError } from '@/src/entities/errors/auth';
import { InputParseError } from '@/src/entities/errors/common'; 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[]> { export async function getUnits(): Promise<IUnits[]> {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( 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 { useState, useEffect } from 'react';
import mapboxgl from 'mapbox-gl'; import mapboxgl from 'mapbox-gl';
import RecentCrimesLayer from '../layers/recent-crimes-layer';
import EWSAlertLayer from '../layers/ews-alert-layer'; import EWSAlertLayer from '../layers/ews-alert-layer';
import { IIncidentLog } from '@/app/_utils/types/ews'; import { IIncidentLog } from '@/app/_utils/types/ews';

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ import type { ITooltipsControl } from "../controls/top/tooltips"
import type { IUnits } from "@/app/_utils/types/units" import type { IUnits } from "@/app/_utils/types/units"
import UnitsLayer from "./units-layer" import UnitsLayer from "./units-layer"
import DistrictFillLineLayer from "./district-layer" import DistrictFillLineLayer from "./district-layer"
import CrimePopup from "../pop-up/crime-popup"
import TimezoneLayer from "./timezone" import TimezoneLayer from "./timezone"
import FaultLinesLayer from "./fault-lines" import FaultLinesLayer from "./fault-lines"
import EWSAlertLayer from "./ews-alert-layer" 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 type { IIncidentLog } from "@/app/_utils/types/ews"
import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data" import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data"
import RecentIncidentsLayer from "./recent-incidents-layer" import RecentIncidentsLayer from "./recent-incidents-layer"
import IncidentPopup from "../pop-up/incident-popup"
// Interface for crime incident // Interface for crime incident
interface ICrimeIncident { interface ICrimeIncident {
@ -247,24 +248,19 @@ export default function Layers({
} }
const handleCloseDistrictPopup = useCallback(() => { const handleCloseDistrictPopup = useCallback(() => {
console.log("Closing district popup") // console.log("Closing district popup")
animateExtrusionDown() animateExtrusionDown()
handlePopupClose() handlePopupClose()
}, [handlePopupClose, animateExtrusionDown]) }, [handlePopupClose, animateExtrusionDown])
const handleCloseIncidentPopup = useCallback(() => {
console.log("Closing incident popup")
handlePopupClose()
}, [handlePopupClose])
const handleDistrictClick = useCallback( const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => { (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 we're currently interacting with a marker, don't process district click
if (isInteractingWithMarker.current) { 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 return
} }
@ -325,139 +321,6 @@ export default function Layers({
} }
}, [mapboxMap, map]) }, [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(() => { useEffect(() => {
if (selectedDistrictRef.current) { if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id const districtId = selectedDistrictRef.current.id
@ -540,7 +403,7 @@ export default function Layers({
}, [crimes, filterCategory, year, month, crimeDataByDistrict]) }, [crimes, filterCategory, year, month, crimeDataByDistrict])
const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => { 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 this is from a marker click, set the marker interaction flag
if (isMarkerClick) { if (isMarkerClick) {
@ -683,14 +546,7 @@ export default function Layers({
</div> </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, category: incident.properties.category,
} }
console.log("Recent incident clicked:", incidentDetails) // console.log("Recent incident clicked:", incidentDetails)
// Ensure markers stay visible // Ensure markers stay visible
if (map.getLayer("recent-incidents")) { if (map.getLayer("recent-incidents")) {
@ -87,7 +87,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
useEffect(() => { useEffect(() => {
if (!map || !visible) return 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 // Convert incidents to GeoJSON
const recentData = { const recentData = {

View File

@ -41,7 +41,7 @@ export default function UnclusteredPointLayer({
timestamp: new Date(incident.properties.timestamp || Date.now()), 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 // Ensure markers stay visible when clicking on them
if (map.getLayer("unclustered-point")) { if (map.getLayer("unclustered-point")) {

View File

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

View File

@ -5,10 +5,11 @@ import { Badge } from "@/app/_components/ui/badge"
import { Card } from "@/app/_components/ui/card" import { Card } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator" import { Separator } from "@/app/_components/ui/separator"
import { Button } from "@/app/_components/ui/button" 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 { IDistanceResult } from "@/app/_utils/types/crimes"
import { ScrollArea } from "@/app/_components/ui/scroll-area" import { ScrollArea } from "@/app/_components/ui/scroll-area"
import { Skeleton } from "@/app/_components/ui/skeleton" import { Skeleton } from "@/app/_components/ui/skeleton"
import { INearestUnits } from "@/app/(pages)/(admin)/dashboard/crime-management/units/action"
interface IncidentPopupProps { interface IncidentPopupProps {
longitude: number longitude: number
@ -22,8 +23,8 @@ interface IncidentPopupProps {
district?: string district?: string
district_id?: string district_id?: string
} }
distances?: IDistanceResult[] nearestUnit?: INearestUnits[]
isLoadingDistances?: boolean isLoadingNearestUnit?: boolean
} }
export default function IncidentPopup({ export default function IncidentPopup({
@ -31,8 +32,8 @@ export default function IncidentPopup({
latitude, latitude,
onClose, onClose,
incident, incident,
distances = [], nearestUnit = [],
isLoadingDistances = false isLoadingNearestUnit = false
}: IncidentPopupProps) { }: IncidentPopupProps) {
const formatDate = (date?: Date | string) => { const formatDate = (date?: Date | string) => {
@ -130,31 +131,39 @@ export default function IncidentPopup({
)} )}
</div> </div>
{/* Distances to police units section */} {/* NearestUnit to police nearestUnits section */}
<Separator className="my-3" /> <Separator className="my-3" />
<div> <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"> <div className="space-y-2">
<Skeleton className="h-6 w-full" /> <Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" /> <Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" /> <Skeleton className="h-6 w-full" />
</div> </div>
) : distances.length > 0 ? ( ) : nearestUnit.length > 0 ? (
<ScrollArea className="h-[120px] rounded-md border p-2"> <ScrollArea className="h-[100px] rounded-md border p-2">
<div className="space-y-2"> <div className="space-y-2">
{distances.map((item) => ( {nearestUnit.map((unit) => (
<div key={item.unit_code} className="flex justify-between items-center text-xs border-b pb-1"> <div key={unit.code_unit} className="flex justify-between items-center text-xs border-b pb-1">
<div> <div>
<p className="font-medium">{item.unit_name || "Unknown Unit"}</p> <p className="font-medium">
<p className="text-muted-foreground text-[10px]"> {unit.name || "Unknown"}
{item.unit_type || "Police Unit"} <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> </p>
</div> </div>
<Badge variant="outline" className="ml-2"> <Badge variant="outline" className="ml-2 whitespace-nowrap text-blue-600">
{formatDistance(item.distance_meters)} {formatDistance(unit.distance_meters)}
</Badge> </Badge>
</div> </div>
))} ))}
@ -162,7 +171,7 @@ export default function IncidentPopup({
</ScrollArea> </ScrollArea>
) : ( ) : (
<p className="text-xs text-muted-foreground text-center p-2"> <p className="text-xs text-muted-foreground text-center p-2">
No police units data available No nearby units found
</p> </p>
)} )}
</div> </div>
@ -176,28 +185,6 @@ export default function IncidentPopup({
</div> </div>
</div> </div>
</Card> </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> </div>
</Popup> </Popup>
) )

View File

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