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:
parent
f4b6d19eb2
commit
58f033d0e4
|
@ -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),
|
||||||
|
});
|
||||||
|
}
|
|
@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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")) {
|
||||||
|
|
|
@ -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,10 +279,10 @@ 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
|
||||||
|
@ -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,12 +363,16 @@ 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")) {
|
||||||
|
@ -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 = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fungsi untuk setup event handler
|
||||||
|
const setupHandlers = () => {
|
||||||
// Add click event for units-points layer
|
// Add click event for units-points layer
|
||||||
if (map.getLayer("units-points")) {
|
if (map.getLayer("units-points")) {
|
||||||
map.off("click", "units-points", unitClickHandler)
|
map.off("click", "units-points", unitClickHandler)
|
||||||
map.on("click", "units-points", unitClickHandler)
|
map.on("click", "units-points", unitClickHandler)
|
||||||
|
|
||||||
// Change cursor on hover
|
|
||||||
map.on("mouseenter", "units-points", handleMouseEnter)
|
map.on("mouseenter", "units-points", handleMouseEnter)
|
||||||
map.on("mouseleave", "units-points", handleMouseLeave)
|
map.on("mouseleave", "units-points", handleMouseLeave)
|
||||||
|
console.log("✅ Unit points handler attached")
|
||||||
|
} else {
|
||||||
|
console.log("❌ units-points layer not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click event for incidents-points layer
|
// Add click event for incidents-points layer
|
||||||
if (map.getLayer("incidents-points")) {
|
if (map.getLayer("incidents-points")) {
|
||||||
map.off("click", "incidents-points", incidentClickHandler)
|
map.off("click", "incidents-points", incidentClickHandler)
|
||||||
map.on("click", "incidents-points", incidentClickHandler)
|
map.on("click", "incidents-points", incidentClickHandler)
|
||||||
|
|
||||||
// Change cursor on hover
|
|
||||||
map.on("mouseenter", "incidents-points", handleMouseEnter)
|
map.on("mouseenter", "incidents-points", handleMouseEnter)
|
||||||
map.on("mouseleave", "incidents-points", handleMouseLeave)
|
map.on("mouseleave", "incidents-points", handleMouseLeave)
|
||||||
|
console.log("✅ Incident points handler attached")
|
||||||
|
} else {
|
||||||
|
console.log("❌ incidents-points layer not found")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup handlers langsung
|
||||||
|
setupHandlers()
|
||||||
|
|
||||||
|
// 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 () => {
|
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,6 +651,8 @@ 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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
Loading…
Reference in New Issue