feat: Add Panic Button Demo component and EWS Alert Layer for incident management
- Implemented PanicButtonDemo component for triggering alerts with varying priorities. - Created EWSAlertLayer to manage and display active incidents on the map. - Added styles for alert markers and status indicators. - Developed mock data utilities for generating and managing incident logs. - Defined TypeScript interfaces for incident logs and locations.
This commit is contained in:
parent
3448945f4d
commit
8a1a4320c2
|
@ -1,7 +1,6 @@
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import "@/app/_styles/globals.css";
|
import "@/app/_styles/globals.css";
|
||||||
import "@/app/_styles/ui.css";
|
|
||||||
import ReactQueryProvider from "@/app/_lib/react-query-provider";
|
import ReactQueryProvider from "@/app/_lib/react-query-provider";
|
||||||
import { Toaster } from "@/app/_components/ui/sonner";
|
import { Toaster } from "@/app/_components/ui/sonner";
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,108 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/app/_components/ui/button';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
|
ShieldAlert,
|
||||||
|
Radio,
|
||||||
|
RadioTower,
|
||||||
|
Shield
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/app/_lib/utils';
|
||||||
|
import { Badge } from '@/app/_components/ui/badge';
|
||||||
|
import { IIncidentLog } from '@/app/_utils/types/ews';
|
||||||
|
|
||||||
|
interface PanicButtonDemoProps {
|
||||||
|
onTriggerAlert: (priority: 'high' | 'medium' | 'low') => void;
|
||||||
|
onResolveAllAlerts: () => void;
|
||||||
|
activeIncidents: IIncidentLog[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PanicButtonDemo({
|
||||||
|
onTriggerAlert,
|
||||||
|
onResolveAllAlerts,
|
||||||
|
activeIncidents,
|
||||||
|
className
|
||||||
|
}: PanicButtonDemoProps) {
|
||||||
|
const [isTriggering, setIsTriggering] = useState(false);
|
||||||
|
|
||||||
|
const handleTriggerPanic = (priority: 'high' | 'medium' | 'low') => {
|
||||||
|
setIsTriggering(true);
|
||||||
|
onTriggerAlert(priority);
|
||||||
|
|
||||||
|
// Reset animation
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsTriggering(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("border border-muted bg-background p-3 rounded-lg shadow-xl", className)}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-bold flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-red-500" />
|
||||||
|
<span>EWS Panic Button Demo</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{activeIncidents.length > 0 && (
|
||||||
|
<Badge variant="destructive" className="ml-2 animate-pulse">
|
||||||
|
{activeIncidents.length} Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="lg"
|
||||||
|
className={cn(
|
||||||
|
"bg-red-600 hover:bg-red-700 flex items-center gap-2 transition-all",
|
||||||
|
isTriggering && "animate-ping"
|
||||||
|
)}
|
||||||
|
onClick={() => handleTriggerPanic('high')}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
<span>SEND HIGH PRIORITY ALERT</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="bg-amber-600 hover:bg-amber-700 flex items-center gap-2"
|
||||||
|
onClick={() => handleTriggerPanic('medium')}
|
||||||
|
>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
<span>Medium Priority</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-600 text-blue-600 hover:bg-blue-100 flex items-center gap-2"
|
||||||
|
onClick={() => handleTriggerPanic('low')}
|
||||||
|
>
|
||||||
|
<RadioTower className="h-4 w-4" />
|
||||||
|
<span>Low Priority</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeIncidents.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3 border-green-600 text-green-600 hover:bg-green-100"
|
||||||
|
onClick={onResolveAllAlerts}
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4 mr-2" />
|
||||||
|
<span>Resolve All Alerts</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Simulates a mobile app panic button activation in the Jember area.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ export default function CrimeMap() {
|
||||||
const [showUnclustered, setShowUnclustered] = useState(true)
|
const [showUnclustered, setShowUnclustered] = useState(true)
|
||||||
const [useAllYears, setUseAllYears] = useState<boolean>(false)
|
const [useAllYears, setUseAllYears] = useState<boolean>(false)
|
||||||
const [useAllMonths, setUseAllMonths] = useState<boolean>(false)
|
const [useAllMonths, setUseAllMonths] = useState<boolean>(false)
|
||||||
|
const [showEWS, setShowEWS] = useState<boolean>(true)
|
||||||
|
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
@ -185,6 +186,9 @@ export default function CrimeMap() {
|
||||||
setUseAllYears(false);
|
setUseAllYears(false);
|
||||||
setUseAllMonths(false);
|
setUseAllMonths(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable EWS in all modes for demo purposes
|
||||||
|
setShowEWS(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTimelineLayer = activeControl === "timeline";
|
const showTimelineLayer = activeControl === "timeline";
|
||||||
|
@ -218,87 +222,90 @@ export default function CrimeMap() {
|
||||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mapbox-container relative h-[600px]" ref={mapContainerRef}>
|
<div className="mapbox-container overlay-bg vertical-reveal relative h-[600px]" ref={mapContainerRef}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out",
|
||||||
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
||||||
)}>
|
)}>
|
||||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
<div className="">
|
||||||
<Layers
|
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
||||||
crimes={filteredCrimes || []}
|
<Layers
|
||||||
units={fetchedUnits || []}
|
crimes={filteredCrimes || []}
|
||||||
year={selectedYear.toString()}
|
units={fetchedUnits || []}
|
||||||
month={selectedMonth.toString()}
|
year={selectedYear.toString()}
|
||||||
filterCategory={selectedCategory}
|
month={selectedMonth.toString()}
|
||||||
activeControl={activeControl}
|
filterCategory={selectedCategory}
|
||||||
useAllData={useAllYears}
|
activeControl={activeControl}
|
||||||
/>
|
useAllData={useAllYears}
|
||||||
|
showEWS={showEWS}
|
||||||
|
/>
|
||||||
|
|
||||||
{isFullscreen && (
|
{isFullscreen && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute flex w-full p-2">
|
<div className="absolute flex w-full p-2">
|
||||||
<Tooltips
|
<Tooltips
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
onControlChange={handleControlChange}
|
onControlChange={handleControlChange}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
setSelectedYear={setSelectedYear}
|
setSelectedYear={setSelectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
setSelectedMonth={setSelectedMonth}
|
setSelectedMonth={setSelectedMonth}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
setSelectedCategory={setSelectedCategory}
|
setSelectedCategory={setSelectedCategory}
|
||||||
availableYears={availableYears || []}
|
availableYears={availableYears || []}
|
||||||
categories={categories}
|
|
||||||
crimes={filteredCrimes}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CrimeSidebar
|
|
||||||
crimes={filteredCrimes || []}
|
|
||||||
defaultCollapsed={sidebarCollapsed}
|
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
selectedYear={selectedYear}
|
|
||||||
selectedMonth={selectedMonth}
|
|
||||||
/>
|
|
||||||
{isFullscreen && (
|
|
||||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
|
||||||
{showClusters && (
|
|
||||||
<MapLegend position="bottom-right" />
|
|
||||||
)}
|
|
||||||
{showUnclustered && !showClusters && (
|
|
||||||
<MapLegend position="bottom-right" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isFullscreen && showUnitsLayer && (
|
|
||||||
<div className="absolute bottom-20 right-0 z-10 p-2">
|
|
||||||
<UnitsLegend
|
|
||||||
categories={categories}
|
categories={categories}
|
||||||
position="bottom-right"
|
crimes={filteredCrimes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{isFullscreen && showTimelineLayer && (
|
<CrimeSidebar
|
||||||
<div className="absolute flex bottom-20 right-0 z-10 p-2">
|
crimes={filteredCrimes || []}
|
||||||
<TimelineLegend position="bottom-right" />
|
defaultCollapsed={sidebarCollapsed}
|
||||||
</div>
|
selectedCategory={selectedCategory}
|
||||||
)}
|
selectedYear={selectedYear}
|
||||||
</>
|
selectedMonth={selectedMonth}
|
||||||
)}
|
/>
|
||||||
|
{isFullscreen && (
|
||||||
|
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||||
|
{showClusters && (
|
||||||
|
<MapLegend position="bottom-right" />
|
||||||
|
)}
|
||||||
|
{showUnclustered && !showClusters && (
|
||||||
|
<MapLegend position="bottom-right" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFullscreen && (
|
{isFullscreen && showUnitsLayer && (
|
||||||
<div className="absolute flex w-full bottom-0">
|
<div className="absolute bottom-20 right-0 z-10 p-2">
|
||||||
<CrimeTimelapse
|
<UnitsLegend
|
||||||
startYear={2020}
|
categories={categories}
|
||||||
endYear={2024}
|
position="bottom-right"
|
||||||
autoPlay={false}
|
/>
|
||||||
onChange={handleTimelineChange}
|
</div>
|
||||||
onPlayingChange={handleTimelinePlayingChange}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
{isFullscreen && showTimelineLayer && (
|
||||||
)}
|
<div className="absolute flex bottom-20 right-0 z-10 p-2">
|
||||||
</MapView>
|
<TimelineLegend position="bottom-right" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFullscreen && (
|
||||||
|
<div className="absolute flex w-full bottom-0">
|
||||||
|
<CrimeTimelapse
|
||||||
|
startYear={2020}
|
||||||
|
endYear={2024}
|
||||||
|
autoPlay={false}
|
||||||
|
onChange={handleTimelineChange}
|
||||||
|
onPlayingChange={handleTimelinePlayingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</MapView>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -14,40 +14,50 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
||||||
|
|
||||||
// Function to add coastline layer
|
// Function to add coastline layer
|
||||||
function addCoastline() {
|
function addCoastline() {
|
||||||
const sourceId = 'coastline';
|
const sourceId = 'coastline_id';
|
||||||
const layerId = 'outline-coastline';
|
const layerId = 'outline-coastline';
|
||||||
|
|
||||||
// Make sure map is defined
|
// Make sure map is defined
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if the source already exists
|
// More robust check if the source already exists
|
||||||
if (!map.getSource(sourceId)) {
|
let sourceExists = false;
|
||||||
|
try {
|
||||||
|
sourceExists = !!map.getSource(sourceId);
|
||||||
|
} catch (e) {
|
||||||
|
sourceExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceExists) {
|
||||||
// Add coastline data source
|
// Add coastline data source
|
||||||
fetch('/geojson/garis_pantai.geojson')
|
fetch('/geojson/garis_pantai.geojson')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Add coastline data source
|
// Double-check the source doesn't exist right before adding
|
||||||
map.addSource(sourceId, {
|
if (!map.getSource(sourceId)) {
|
||||||
type: 'geojson',
|
// Add coastline data source
|
||||||
generateId: true,
|
map.addSource(sourceId, {
|
||||||
data: data
|
type: 'geojson',
|
||||||
});
|
generateId: true,
|
||||||
|
data: data
|
||||||
|
});
|
||||||
|
|
||||||
// Add coastline layer
|
// Add coastline layer
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
'id': layerId,
|
'id': layerId,
|
||||||
'type': 'line',
|
'type': 'line',
|
||||||
'source': sourceId,
|
'source': sourceId,
|
||||||
'layout': {
|
'layout': {
|
||||||
'visibility': visible ? 'visible' : 'none',
|
'visibility': visible ? 'visible' : 'none',
|
||||||
},
|
},
|
||||||
'paint': {
|
'paint': {
|
||||||
'line-color': ['get', 'color'],
|
'line-color': '#1a1a1a', // dull white color instead of ['get', 'color']
|
||||||
'line-width': 5,
|
'line-width': 5,
|
||||||
'line-opacity': 1
|
'line-opacity': 1
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Error fetching coastline data:', error);
|
console.error('Error fetching coastline data:', error);
|
||||||
|
@ -71,7 +81,7 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
||||||
if (!map || !map.getStyle()) return;
|
if (!map || !map.getStyle()) return;
|
||||||
|
|
||||||
const layerId = 'outline-coastline';
|
const layerId = 'outline-coastline';
|
||||||
const sourceId = 'coastline';
|
const sourceId = 'coastline_id';
|
||||||
|
|
||||||
if (map.getLayer(layerId)) {
|
if (map.getLayer(layerId)) {
|
||||||
map.removeLayer(layerId);
|
map.removeLayer(layerId);
|
||||||
|
@ -92,14 +102,24 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
||||||
if (map.loaded() && map.isStyleLoaded()) {
|
if (map.loaded() && map.isStyleLoaded()) {
|
||||||
addCoastline();
|
addCoastline();
|
||||||
} else {
|
} else {
|
||||||
// Use multiple events to catch map ready state
|
// Reduce event listeners to minimize duplicates
|
||||||
map.on('load', addCoastline);
|
const addLayerOnce = () => {
|
||||||
map.on('style.load', addCoastline);
|
// Remove all listeners after first successful execution
|
||||||
map.on('styledata', addCoastline);
|
map.off('load', addLayerOnce);
|
||||||
|
map.off('style.load', addLayerOnce);
|
||||||
|
map.off('styledata', addLayerOnce);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
addCoastline();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on('load', addLayerOnce);
|
||||||
|
map.on('style.load', addLayerOnce);
|
||||||
|
map.on('styledata', addLayerOnce);
|
||||||
|
|
||||||
// Fallback timeout
|
// Fallback timeout
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
addCoastline();
|
addLayerOnce();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -0,0 +1,318 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { IIncidentLog, EWSStatus } from '@/app/_utils/types/ews';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
import DigitalClock from '../markers/digital-clock';
|
||||||
|
import { Badge } from '@/app/_components/ui/badge';
|
||||||
|
import { Button } from '@/app/_components/ui/button';
|
||||||
|
|
||||||
|
interface EWSAlertLayerProps {
|
||||||
|
map: mapboxgl.Map | null;
|
||||||
|
incidents?: IIncidentLog[];
|
||||||
|
onIncidentResolved?: (id: string) => void;
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EWSAlertLayer({
|
||||||
|
map,
|
||||||
|
incidents = [],
|
||||||
|
onIncidentResolved,
|
||||||
|
visible = true
|
||||||
|
}: EWSAlertLayerProps) {
|
||||||
|
const [ewsStatus, setEwsStatus] = useState<EWSStatus>('idle');
|
||||||
|
const [activeIncidents, setActiveIncidents] = useState<IIncidentLog[]>([]);
|
||||||
|
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map());
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const pulsingDotsRef = useRef<Record<string, HTMLDivElement>>({}); // For animation reference
|
||||||
|
|
||||||
|
// Initialize audio
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
// Try multiple possible audio sources in order of preference
|
||||||
|
const possibleSources = [
|
||||||
|
'/sounds/alert.mp3',
|
||||||
|
'/alert.mp3',
|
||||||
|
'/sounds/error.mp3',
|
||||||
|
'/error.mp3',
|
||||||
|
'/sounds/notification.mp3'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try to load the first audio source
|
||||||
|
alertAudioRef.current = new Audio("/sounds/error-2-126514.mp3");
|
||||||
|
|
||||||
|
// Add error handling to try alternative sources
|
||||||
|
alertAudioRef.current.addEventListener('error', (e) => {
|
||||||
|
// console.warn(`Failed to load audio from ${possibleSources[0]}, trying fallback sources`, e);
|
||||||
|
|
||||||
|
// Try each source in succession
|
||||||
|
// for (let i = 1; i < possibleSources.length; i++) {
|
||||||
|
// try {
|
||||||
|
// alertAudioRef.current = new Audio(possibleSources[i]);
|
||||||
|
// console.log(`Using fallback audio source: ${possibleSources[i]}`);
|
||||||
|
// break;
|
||||||
|
// } catch (err) {
|
||||||
|
// console.warn(`Fallback audio ${possibleSources[i]} also failed`, err);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
});
|
||||||
|
|
||||||
|
alertAudioRef.current.volume = 0.5;
|
||||||
|
|
||||||
|
// Loop handling - stop after 1 minute
|
||||||
|
let loopStartTime: number | null = null;
|
||||||
|
|
||||||
|
alertAudioRef.current.addEventListener('ended', () => {
|
||||||
|
// Initialize start time on first play
|
||||||
|
if (loopStartTime === null) {
|
||||||
|
loopStartTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 1 minute has passed
|
||||||
|
if (Date.now() - loopStartTime < 60000) { // 60000ms = 1 minute
|
||||||
|
alertAudioRef.current?.play().catch(err =>
|
||||||
|
console.error("Error playing looped alert sound:", err));
|
||||||
|
} else {
|
||||||
|
loopStartTime = null; // Reset for future alerts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preload the audio
|
||||||
|
alertAudioRef.current.load();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Could not initialize alert audio:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (alertAudioRef.current) {
|
||||||
|
alertAudioRef.current.pause();
|
||||||
|
alertAudioRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update active incidents when incidents prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
const newActiveIncidents = incidents.filter(inc => inc.status === 'active');
|
||||||
|
setActiveIncidents(newActiveIncidents);
|
||||||
|
|
||||||
|
// Update EWS status
|
||||||
|
if (newActiveIncidents.length > 0) {
|
||||||
|
setEwsStatus('alert');
|
||||||
|
// Play alert sound with error handling
|
||||||
|
if (alertAudioRef.current) {
|
||||||
|
alertAudioRef.current.play()
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Error playing alert sound:", err);
|
||||||
|
// Create a simple beep as fallback
|
||||||
|
try {
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||||
|
oscillator.connect(audioContext.destination);
|
||||||
|
oscillator.start();
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.5);
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error("Fallback audio also failed:", fallbackErr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEwsStatus('idle');
|
||||||
|
}
|
||||||
|
}, [incidents]);
|
||||||
|
|
||||||
|
// Handle marker creation, animation, and cleanup
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !visible) return;
|
||||||
|
|
||||||
|
// Clear any existing markers
|
||||||
|
markersRef.current.forEach(marker => marker.remove());
|
||||||
|
markersRef.current.clear();
|
||||||
|
pulsingDotsRef.current = {};
|
||||||
|
|
||||||
|
// Cancel any ongoing animations
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new markers for all active incidents
|
||||||
|
activeIncidents.forEach(incident => {
|
||||||
|
// Don't add if marker already exists
|
||||||
|
if (markersRef.current.has(incident.id)) return;
|
||||||
|
|
||||||
|
const { latitude, longitude } = incident.location;
|
||||||
|
|
||||||
|
// Create marker element
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'ews-alert-marker';
|
||||||
|
|
||||||
|
// Create a wrapper for the pulsing effect
|
||||||
|
const pulsingDot = document.createElement('div');
|
||||||
|
pulsingDot.className = 'pulsing-dot';
|
||||||
|
pulsingDotsRef.current[incident.id] = pulsingDot;
|
||||||
|
|
||||||
|
// Create the content for the marker
|
||||||
|
const contentElement = document.createElement('div');
|
||||||
|
contentElement.className = 'ews-alert-content';
|
||||||
|
|
||||||
|
// Use React for the popup content
|
||||||
|
const root = createRoot(contentElement);
|
||||||
|
root.render(
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute z-50 -top-4 -left-4 w-8 h-8 rounded-full bg-red-600 animate-ping" />
|
||||||
|
<div className="relative z-40 bg-red-600 text-white p-2 rounded-lg shadow-lg min-w-[200px] max-w-[300px]">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<Badge variant="outline" className={`
|
||||||
|
${incident.priority === 'high' ? 'bg-red-700 text-white' :
|
||||||
|
incident.priority === 'medium' ? 'bg-amber-600 text-white' :
|
||||||
|
'bg-blue-600 text-white'}
|
||||||
|
`}>
|
||||||
|
{incident.priority.toUpperCase()} PRIORITY
|
||||||
|
</Badge>
|
||||||
|
<div className="text-xs">
|
||||||
|
<DigitalClock
|
||||||
|
timeZone="Asia/Jakarta"
|
||||||
|
format="24h"
|
||||||
|
showSeconds={true}
|
||||||
|
className="font-mono bg-black/50 px-1 rounded text-red-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-bold flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{incident.category || "Emergency Alert"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="text-sm mt-1">
|
||||||
|
<p className="font-bold">{incident.location.district}</p>
|
||||||
|
<p className="text-xs">{incident.location.address}</p>
|
||||||
|
<p className="text-xs mt-1">Reported by: {incident.reporter.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-2">
|
||||||
|
<Badge variant="secondary" className="bg-red-800 text-white">
|
||||||
|
ID: {incident.id}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-green-700 text-white border-0 hover:bg-green-600 h-6 text-xs px-2"
|
||||||
|
onClick={() => onIncidentResolved?.(incident.id)}
|
||||||
|
>
|
||||||
|
Respond
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the elements to the marker
|
||||||
|
el.appendChild(pulsingDot);
|
||||||
|
el.appendChild(contentElement);
|
||||||
|
|
||||||
|
// Create and add the marker
|
||||||
|
const marker = new mapboxgl.Marker({
|
||||||
|
element: el,
|
||||||
|
anchor: 'center'
|
||||||
|
})
|
||||||
|
.setLngLat([longitude, latitude])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
markersRef.current.set(incident.id, marker);
|
||||||
|
|
||||||
|
// Fly to the incident if it's new
|
||||||
|
const isNewIncident = activeIncidents.length > 0 &&
|
||||||
|
incident.id === activeIncidents[activeIncidents.length - 1].id;
|
||||||
|
|
||||||
|
if (isNewIncident) {
|
||||||
|
// Dispatch custom flyTo event
|
||||||
|
const flyToEvent = new CustomEvent('mapbox_fly_to', {
|
||||||
|
detail: {
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
zoom: 15,
|
||||||
|
bearing: 0,
|
||||||
|
pitch: 60,
|
||||||
|
duration: 2000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.getContainer().dispatchEvent(flyToEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup animation for pulsing dots
|
||||||
|
const animatePulsingDots = () => {
|
||||||
|
Object.entries(pulsingDotsRef.current).forEach(([id, el]) => {
|
||||||
|
const scale = 1 + 0.5 * Math.sin(Date.now() / 200); // Pulsing effect
|
||||||
|
el.style.transform = `scale(${scale})`;
|
||||||
|
|
||||||
|
// Add rotation for more visual effect
|
||||||
|
const rotation = (Date.now() / 50) % 360;
|
||||||
|
el.style.transform += ` rotate(${rotation}deg)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animatePulsingDots);
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animatePulsingDots);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (animationFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
markersRef.current.forEach(marker => marker.remove());
|
||||||
|
markersRef.current.clear();
|
||||||
|
};
|
||||||
|
}, [map, activeIncidents, visible, onIncidentResolved]);
|
||||||
|
|
||||||
|
// Create a floating EWS status indicator when in alert mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || ewsStatus === 'idle') return;
|
||||||
|
|
||||||
|
// Create status indicator element if it doesn't exist
|
||||||
|
let statusContainer = document.getElementById('ews-status-indicator');
|
||||||
|
|
||||||
|
if (!statusContainer) {
|
||||||
|
statusContainer = document.createElement('div');
|
||||||
|
statusContainer.id = 'ews-status-indicator';
|
||||||
|
statusContainer.className = 'absolute top-16 left-1/2 transform -translate-x-1/2 z-50';
|
||||||
|
map.getContainer().appendChild(statusContainer);
|
||||||
|
|
||||||
|
// Use React for the status indicator
|
||||||
|
const root = createRoot(statusContainer);
|
||||||
|
root.render(
|
||||||
|
<div className={`
|
||||||
|
py-2 px-4 rounded-full shadow-lg flex items-center gap-2
|
||||||
|
${ewsStatus === 'alert' ? 'bg-red-600 text-white' : 'bg-amber-500 text-white'}
|
||||||
|
animate-pulse
|
||||||
|
`}>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span className="font-bold uppercase tracking-wider">
|
||||||
|
{ewsStatus === 'alert'
|
||||||
|
? `Alert: ${activeIncidents.length} Active Emergencies`
|
||||||
|
: 'System Active'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (statusContainer && statusContainer.parentNode) {
|
||||||
|
statusContainer.parentNode.removeChild(statusContainer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [map, ewsStatus, activeIncidents.length]);
|
||||||
|
|
||||||
|
return null; // This component doesn't render anything directly
|
||||||
|
}
|
|
@ -23,6 +23,12 @@ import CrimePopup from "../pop-up/crime-popup"
|
||||||
import TimeZonesDisplay from "./timezone"
|
import TimeZonesDisplay from "./timezone"
|
||||||
import TimezoneLayer from "./timezone"
|
import TimezoneLayer from "./timezone"
|
||||||
import FaultLinesLayer from "./fault-lines"
|
import FaultLinesLayer from "./fault-lines"
|
||||||
|
import CoastlineLayer from "./coastline"
|
||||||
|
import EWSAlertLayer from "./ews-alert-layer"
|
||||||
|
import PanicButtonDemo from "../controls/panic-button-demo"
|
||||||
|
|
||||||
|
import { IIncidentLog } from "@/app/_utils/types/ews"
|
||||||
|
import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data"
|
||||||
|
|
||||||
// Interface for crime incident
|
// Interface for crime incident
|
||||||
interface ICrimeIncident {
|
interface ICrimeIncident {
|
||||||
|
@ -67,6 +73,7 @@ interface LayersProps {
|
||||||
activeControl: ITooltips
|
activeControl: ITooltips
|
||||||
tilesetId?: string
|
tilesetId?: string
|
||||||
useAllData?: boolean
|
useAllData?: boolean
|
||||||
|
showEWS?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layers({
|
export default function Layers({
|
||||||
|
@ -79,6 +86,7 @@ export default function Layers({
|
||||||
activeControl,
|
activeControl,
|
||||||
tilesetId = MAPBOX_TILESET_ID,
|
tilesetId = MAPBOX_TILESET_ID,
|
||||||
useAllData = false,
|
useAllData = false,
|
||||||
|
showEWS = true,
|
||||||
}: LayersProps) {
|
}: LayersProps) {
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
@ -96,25 +104,47 @@ export default function Layers({
|
||||||
|
|
||||||
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
|
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
|
||||||
|
|
||||||
// Handle popup close with a common reset pattern
|
const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([]);
|
||||||
|
const [showPanicDemo, setShowPanicDemo] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEwsIncidents(getAllIncidents());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTriggerAlert = useCallback((priority: 'high' | 'medium' | 'low') => {
|
||||||
|
const newIncident = addMockIncident({ priority });
|
||||||
|
setEwsIncidents(getAllIncidents());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResolveIncident = useCallback((id: string) => {
|
||||||
|
resolveIncident(id);
|
||||||
|
setEwsIncidents(getAllIncidents());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResolveAllAlerts = useCallback(() => {
|
||||||
|
ewsIncidents.forEach(incident => {
|
||||||
|
if (incident.status === 'active') {
|
||||||
|
resolveIncident(incident.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setEwsIncidents(getAllIncidents());
|
||||||
|
}, [ewsIncidents]);
|
||||||
|
|
||||||
const handlePopupClose = useCallback(() => {
|
const handlePopupClose = useCallback(() => {
|
||||||
// Reset selected state
|
|
||||||
selectedDistrictRef.current = null
|
selectedDistrictRef.current = null
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null)
|
||||||
setSelectedIncident(null)
|
setSelectedIncident(null)
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null)
|
||||||
|
|
||||||
// Reset map view/camera
|
|
||||||
if (map) {
|
if (map) {
|
||||||
map.easeTo({
|
map.easeTo({
|
||||||
zoom: BASE_ZOOM,
|
zoom: BASE_ZOOM,
|
||||||
pitch: BASE_PITCH,
|
pitch: BASE_PITCH,
|
||||||
bearing: BASE_BEARING,
|
bearing: BASE_BEARING,
|
||||||
duration: 1500,
|
duration: 1500,
|
||||||
easing: (t) => t * (2 - t), // easeOutQuad
|
easing: (t) => t * (2 - t),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show all clusters again when closing popup
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
||||||
}
|
}
|
||||||
|
@ -122,7 +152,6 @@ export default function Layers({
|
||||||
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update fill color for all districts
|
|
||||||
if (map.getLayer("district-fill")) {
|
if (map.getLayer("district-fill")) {
|
||||||
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
|
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
|
||||||
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
||||||
|
@ -130,32 +159,26 @@ export default function Layers({
|
||||||
}
|
}
|
||||||
}, [map, crimeDataByDistrict])
|
}, [map, crimeDataByDistrict])
|
||||||
|
|
||||||
// Handle district popup close specifically
|
|
||||||
const handleCloseDistrictPopup = useCallback(() => {
|
const handleCloseDistrictPopup = useCallback(() => {
|
||||||
console.log("Closing district popup")
|
console.log("Closing district popup")
|
||||||
handlePopupClose()
|
handlePopupClose()
|
||||||
}, [handlePopupClose])
|
}, [handlePopupClose])
|
||||||
|
|
||||||
// Handle incident popup close specifically
|
|
||||||
const handleCloseIncidentPopup = useCallback(() => {
|
const handleCloseIncidentPopup = useCallback(() => {
|
||||||
console.log("Closing incident popup")
|
console.log("Closing incident popup")
|
||||||
handlePopupClose()
|
handlePopupClose()
|
||||||
}, [handlePopupClose])
|
}, [handlePopupClose])
|
||||||
|
|
||||||
// Handle district clicks
|
|
||||||
const handleDistrictClick = useCallback(
|
const handleDistrictClick = useCallback(
|
||||||
(feature: IDistrictFeature) => {
|
(feature: IDistrictFeature) => {
|
||||||
console.log("District clicked:", feature)
|
console.log("District clicked:", feature)
|
||||||
|
|
||||||
// Clear any incident selection when showing a district
|
|
||||||
setSelectedIncident(null)
|
setSelectedIncident(null)
|
||||||
|
|
||||||
// Set the selected district
|
|
||||||
setSelectedDistrict(feature)
|
setSelectedDistrict(feature)
|
||||||
selectedDistrictRef.current = feature
|
selectedDistrictRef.current = feature
|
||||||
setFocusedDistrictId(feature.id)
|
setFocusedDistrictId(feature.id)
|
||||||
|
|
||||||
// Fly to the district
|
|
||||||
if (map && feature.longitude && feature.latitude) {
|
if (map && feature.longitude && feature.latitude) {
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: [feature.longitude, feature.latitude],
|
center: [feature.longitude, feature.latitude],
|
||||||
|
@ -166,7 +189,6 @@ export default function Layers({
|
||||||
easing: (t) => t * (2 - t),
|
easing: (t) => t * (2 - t),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hide clusters when focusing on district
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
||||||
}
|
}
|
||||||
|
@ -178,7 +200,6 @@ export default function Layers({
|
||||||
[map],
|
[map],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set up custom event handler for fly-to events
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapboxMap) return
|
if (!mapboxMap) return
|
||||||
|
|
||||||
|
@ -206,7 +227,6 @@ export default function Layers({
|
||||||
}
|
}
|
||||||
}, [mapboxMap, map])
|
}, [mapboxMap, map])
|
||||||
|
|
||||||
// Handle incident click events
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapboxMap) return
|
if (!mapboxMap) return
|
||||||
|
|
||||||
|
@ -214,13 +234,11 @@ export default function Layers({
|
||||||
const customEvent = e as CustomEvent
|
const customEvent = e as CustomEvent
|
||||||
console.log("Received incident_click event in layers:", customEvent.detail)
|
console.log("Received incident_click event in layers:", customEvent.detail)
|
||||||
|
|
||||||
// Enhanced error checking
|
|
||||||
if (!customEvent.detail) {
|
if (!customEvent.detail) {
|
||||||
console.error("Empty incident click event data")
|
console.error("Empty incident click event data")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow for different property names in the event data
|
|
||||||
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
|
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
|
||||||
|
|
||||||
if (!incidentId) {
|
if (!incidentId) {
|
||||||
|
@ -230,10 +248,8 @@ export default function Layers({
|
||||||
|
|
||||||
console.log("Looking for incident with ID:", incidentId)
|
console.log("Looking for incident with ID:", incidentId)
|
||||||
|
|
||||||
// Improved incident finding
|
|
||||||
let foundIncident: ICrimeIncident | undefined
|
let foundIncident: ICrimeIncident | undefined
|
||||||
|
|
||||||
// First try to use the data directly from the event if it has all needed properties
|
|
||||||
if (
|
if (
|
||||||
customEvent.detail.latitude !== undefined &&
|
customEvent.detail.latitude !== undefined &&
|
||||||
customEvent.detail.longitude !== undefined &&
|
customEvent.detail.longitude !== undefined &&
|
||||||
|
@ -252,7 +268,6 @@ export default function Layers({
|
||||||
address: customEvent.detail.address,
|
address: customEvent.detail.address,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Otherwise search through the crimes data
|
|
||||||
for (const crime of crimes) {
|
for (const crime of crimes) {
|
||||||
for (const incident of crime.crime_incidents) {
|
for (const incident of crime.crime_incidents) {
|
||||||
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
|
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
|
||||||
|
@ -288,20 +303,16 @@ export default function Layers({
|
||||||
|
|
||||||
console.log("Setting selected incident:", foundIncident)
|
console.log("Setting selected incident:", foundIncident)
|
||||||
|
|
||||||
// Clear any existing district selection first
|
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null)
|
||||||
selectedDistrictRef.current = null
|
selectedDistrictRef.current = null
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null)
|
||||||
|
|
||||||
// Set the selected incident
|
|
||||||
setSelectedIncident(foundIncident)
|
setSelectedIncident(foundIncident)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listeners to both the map canvas and document
|
|
||||||
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
|
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
|
||||||
document.addEventListener("incident_click", handleIncidentClickEvent as EventListener)
|
document.addEventListener("incident_click", handleIncidentClickEvent as EventListener)
|
||||||
|
|
||||||
// For debugging purposes, log when this effect runs
|
|
||||||
console.log("Set up incident click event listener")
|
console.log("Set up incident click event listener")
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -313,7 +324,6 @@ export default function Layers({
|
||||||
}
|
}
|
||||||
}, [mapboxMap, crimes, setFocusedDistrictId])
|
}, [mapboxMap, crimes, setFocusedDistrictId])
|
||||||
|
|
||||||
// Update selected district when year/month/filter changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDistrictRef.current) {
|
if (selectedDistrictRef.current) {
|
||||||
const districtId = selectedDistrictRef.current.id
|
const districtId = selectedDistrictRef.current.id
|
||||||
|
@ -395,7 +405,6 @@ export default function Layers({
|
||||||
}
|
}
|
||||||
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
|
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
|
||||||
|
|
||||||
// Make sure we have a defined handler for setFocusedDistrictId
|
|
||||||
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)
|
||||||
setFocusedDistrictId(id)
|
setFocusedDistrictId(id)
|
||||||
|
@ -403,23 +412,18 @@ export default function Layers({
|
||||||
|
|
||||||
if (!visible) return null
|
if (!visible) return null
|
||||||
|
|
||||||
// Determine which layers should be visible based on the active control
|
|
||||||
const showDistrictLayer = activeControl === "incidents"
|
const showDistrictLayer = activeControl === "incidents"
|
||||||
const showHeatmapLayer = activeControl === "heatmap"
|
const showHeatmapLayer = activeControl === "heatmap"
|
||||||
const showClustersLayer = activeControl === "clusters"
|
const showClustersLayer = activeControl === "clusters"
|
||||||
const showUnitsLayer = activeControl === "units"
|
const showUnitsLayer = activeControl === "units"
|
||||||
const showTimelineLayer = activeControl === "timeline"
|
const showTimelineLayer = activeControl === "timeline"
|
||||||
|
|
||||||
// District fill should only be visible for incidents and clusters
|
|
||||||
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
|
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
|
||||||
|
|
||||||
// Show incident markers for incidents, clusters, AND units modes
|
|
||||||
// But hide for heatmap and timeline
|
|
||||||
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
|
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Standard District Layer with incident points */}
|
|
||||||
<DistrictFillLineLayer
|
<DistrictFillLineLayer
|
||||||
visible={true}
|
visible={true}
|
||||||
map={mapboxMap}
|
map={mapboxMap}
|
||||||
|
@ -433,10 +437,9 @@ export default function Layers({
|
||||||
crimeDataByDistrict={crimeDataByDistrict}
|
crimeDataByDistrict={crimeDataByDistrict}
|
||||||
showFill={showDistrictFill}
|
showFill={showDistrictFill}
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
onDistrictClick={handleDistrictClick} // Add this prop to pass the click handler
|
onDistrictClick={handleDistrictClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Heatmap Layer */}
|
|
||||||
<HeatmapLayer
|
<HeatmapLayer
|
||||||
crimes={crimes}
|
crimes={crimes}
|
||||||
year={year}
|
year={year}
|
||||||
|
@ -448,7 +451,6 @@ export default function Layers({
|
||||||
setFocusedDistrictId={handleSetFocusedDistrictId}
|
setFocusedDistrictId={handleSetFocusedDistrictId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Timeline Layer - make sure this is the only visible layer in timeline mode */}
|
|
||||||
<TimelineLayer
|
<TimelineLayer
|
||||||
crimes={crimes}
|
crimes={crimes}
|
||||||
year={year}
|
year={year}
|
||||||
|
@ -459,7 +461,6 @@ export default function Layers({
|
||||||
useAllData={useAllData}
|
useAllData={useAllData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Units Layer - always show incidents when Units is active */}
|
|
||||||
<UnitsLayer
|
<UnitsLayer
|
||||||
crimes={crimes}
|
crimes={crimes}
|
||||||
units={units}
|
units={units}
|
||||||
|
@ -468,7 +469,6 @@ export default function Layers({
|
||||||
map={mapboxMap}
|
map={mapboxMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */}
|
|
||||||
<ClusterLayer
|
<ClusterLayer
|
||||||
visible={visible && activeControl === "clusters"}
|
visible={visible && activeControl === "clusters"}
|
||||||
map={mapboxMap}
|
map={mapboxMap}
|
||||||
|
@ -479,7 +479,6 @@ export default function Layers({
|
||||||
showClusters={activeControl === "clusters"}
|
showClusters={activeControl === "clusters"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Unclustered Points Layer - now show for both incidents and units modes */}
|
|
||||||
<UnclusteredPointLayer
|
<UnclusteredPointLayer
|
||||||
visible={visible && showIncidentMarkers && !focusedDistrictId}
|
visible={visible && showIncidentMarkers && !focusedDistrictId}
|
||||||
map={mapboxMap}
|
map={mapboxMap}
|
||||||
|
@ -488,7 +487,6 @@ export default function Layers({
|
||||||
focusedDistrictId={focusedDistrictId}
|
focusedDistrictId={focusedDistrictId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* District Popup */}
|
|
||||||
{selectedDistrict && !selectedIncident && (
|
{selectedDistrict && !selectedIncident && (
|
||||||
<>
|
<>
|
||||||
<DistrictPopup
|
<DistrictPopup
|
||||||
|
@ -511,41 +509,38 @@ export default function Layers({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline Layer - only show if active control is timeline */}
|
|
||||||
<TimezoneLayer map={mapboxMap} />
|
<TimezoneLayer map={mapboxMap} />
|
||||||
|
|
||||||
{/* Fault line layer */}
|
|
||||||
<FaultLinesLayer map={mapboxMap} />
|
<FaultLinesLayer map={mapboxMap} />
|
||||||
|
|
||||||
<CoastlineLayer
|
<CoastlineLayer map={mapboxMap} />
|
||||||
|
|
||||||
{/* Incident Popup */}
|
{showEWS && (
|
||||||
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
<EWSAlertLayer
|
||||||
<CrimePopup
|
map={mapboxMap}
|
||||||
longitude={selectedIncident.longitude}
|
incidents={ewsIncidents}
|
||||||
latitude={selectedIncident.latitude}
|
onIncidentResolved={handleResolveIncident}
|
||||||
onClose={handleCloseIncidentPopup}
|
/>
|
||||||
incident={selectedIncident}
|
)}
|
||||||
|
|
||||||
|
{showEWS && showPanicDemo && (
|
||||||
|
<div className="absolute bottom-20 left-0 z-50 p-2">
|
||||||
|
<PanicButtonDemo
|
||||||
|
onTriggerAlert={handleTriggerAlert}
|
||||||
|
onResolveAllAlerts={handleResolveAllAlerts}
|
||||||
|
activeIncidents={ewsIncidents.filter(inc => inc.status === 'active')}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Debug info for development */}
|
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
||||||
{/* <div
|
<CrimePopup
|
||||||
className="text-red-500 bg-accent"
|
longitude={selectedIncident.longitude}
|
||||||
style={{
|
latitude={selectedIncident.latitude}
|
||||||
position: "absolute",
|
onClose={handleCloseIncidentPopup}
|
||||||
bottom: 10,
|
incident={selectedIncident}
|
||||||
left: 10,
|
/>
|
||||||
backgroundColor: "rgba(255,255,255,0.7)",
|
)}
|
||||||
padding: "5px",
|
|
||||||
zIndex: 999,
|
|
||||||
display: process.env.NODE_ENV === "development" ? "block" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>Selected District: {selectedDistrict ? selectedDistrict.name : "None"}</div>
|
|
||||||
<div>Selected Incident: {selectedIncident ? selectedIncident.id : "None"}</div>
|
|
||||||
<div>Focused District ID: {focusedDistrictId || "None"}</div>
|
|
||||||
</div> */}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ function Jam({ timeZone }: { timeZone: string }) {
|
||||||
const options: Intl.DateTimeFormatOptions = {
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
second: '2-digit', // Added seconds display
|
||||||
hour12: false,
|
hour12: false,
|
||||||
timeZone
|
timeZone
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/* EWS Alert Marker */
|
||||||
|
.ews-alert-marker {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulsing-dot {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(220, 38, 38);
|
||||||
|
box-shadow: 0 0 0 rgba(220, 38, 38, 0.4);
|
||||||
|
transform-origin: center center;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ews-alert-content {
|
||||||
|
position: absolute;
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: 1;
|
||||||
|
top: -8px;
|
||||||
|
left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for alert transitions */
|
||||||
|
@keyframes alert-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
70% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 0 0 10px rgba(255, 82, 82, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(0.95);
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* EWS Status Indicator */
|
||||||
|
#ews-status-indicator {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
|
@ -2,6 +2,9 @@
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import "./ews.css";
|
||||||
|
@import "./ui.css";
|
||||||
|
|
||||||
@import url(//fonts.googleapis.com/css?family=Roboto+Condensed:400,600,700);
|
@import url(//fonts.googleapis.com/css?family=Roboto+Condensed:400,600,700);
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { IIncidentLog } from "../types/ews";
|
||||||
|
|
||||||
|
// Jember area coordinates
|
||||||
|
const JEMBER_LOCATIONS = [
|
||||||
|
{ latitude: -8.172380, longitude: 113.702588, district: "Kaliwates", address: "Jl. Gajah Mada No. 233, Kaliwates" },
|
||||||
|
{ latitude: -8.184859, longitude: 113.668811, district: "Sumbersari", address: "Jl. Kalimantan No.37, Sumbersari" },
|
||||||
|
{ latitude: -8.166498, longitude: 113.722759, district: "Patrang", address: "Jl. Mastrip No. 49, Patrang" },
|
||||||
|
{ latitude: -8.159021, longitude: 113.713175, district: "Jemberlor", address: "Jl. Letjen Panjaitan No. 55, Jemberlor" },
|
||||||
|
{ latitude: -8.192226, longitude: 113.669716, district: "Kebonsari", address: "Perumahan Kebonsari Indah, Blok C-15" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate mock incident log
|
||||||
|
export const generateMockIncident = (override: Partial<IIncidentLog> = {}): IIncidentLog => {
|
||||||
|
const locationIndex = Math.floor(Math.random() * JEMBER_LOCATIONS.length);
|
||||||
|
const location = JEMBER_LOCATIONS[locationIndex];
|
||||||
|
const priorityOptions = ['high', 'medium', 'low'] as const;
|
||||||
|
const priority = override.priority || priorityOptions[Math.floor(Math.random() * priorityOptions.length)];
|
||||||
|
|
||||||
|
const reporters = [
|
||||||
|
{ id: "USR001", name: "Budi Santoso", phone: "081234567890" },
|
||||||
|
{ id: "USR002", name: "Dewi Putri", phone: "085678901234" },
|
||||||
|
{ id: "USR003", name: "Ahmad Rizki", phone: "087890123456" },
|
||||||
|
{ id: "USR004", name: "Siti Nurhaliza", phone: "089012345678" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const reporterIndex = Math.floor(Math.random() * reporters.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: override.id || `INC${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
|
||||||
|
timestamp: override.timestamp || new Date(),
|
||||||
|
location: override.location || {
|
||||||
|
latitude: location.latitude + (Math.random() * 0.01 - 0.005),
|
||||||
|
longitude: location.longitude + (Math.random() * 0.01 - 0.005),
|
||||||
|
address: location.address,
|
||||||
|
district: location.district
|
||||||
|
},
|
||||||
|
status: override.status || 'active',
|
||||||
|
reporter: override.reporter || reporters[reporterIndex],
|
||||||
|
description: override.description || "Panic button activated",
|
||||||
|
category: override.category || "Emergency Alert",
|
||||||
|
priority,
|
||||||
|
response_time: override.response_time,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// List of mock incidents (initially empty)
|
||||||
|
export const mockIncidents: IIncidentLog[] = [];
|
||||||
|
|
||||||
|
// Add a new incident to the mock data
|
||||||
|
export const addMockIncident = (incident: Partial<IIncidentLog> = {}): IIncidentLog => {
|
||||||
|
const newIncident = generateMockIncident(incident);
|
||||||
|
mockIncidents.push(newIncident);
|
||||||
|
return newIncident;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all incidents
|
||||||
|
export const getAllIncidents = (): IIncidentLog[] => {
|
||||||
|
return [...mockIncidents];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get active incidents
|
||||||
|
export const getActiveIncidents = (): IIncidentLog[] => {
|
||||||
|
return mockIncidents.filter(incident => incident.status === 'active');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve an incident
|
||||||
|
export const resolveIncident = (id: string): IIncidentLog | undefined => {
|
||||||
|
const incident = mockIncidents.find(inc => inc.id === id);
|
||||||
|
if (incident) {
|
||||||
|
incident.status = 'resolved';
|
||||||
|
incident.response_time = Math.floor(Math.random() * 300) + 60; // 1-5 minutes response time
|
||||||
|
}
|
||||||
|
return incident;
|
||||||
|
};
|
|
@ -7,6 +7,7 @@ import {
|
||||||
districts,
|
districts,
|
||||||
geographics,
|
geographics,
|
||||||
locations,
|
locations,
|
||||||
|
incident_logs,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
export interface ICrimes extends crimes {
|
export interface ICrimes extends crimes {
|
||||||
|
@ -90,4 +91,17 @@ export interface IDistanceResult {
|
||||||
category_name: string;
|
category_name: string;
|
||||||
district_name: string;
|
district_name: string;
|
||||||
distance_meters: number;
|
distance_meters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIncidentLogs {
|
||||||
|
id: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
source: string;
|
||||||
|
time: Date;
|
||||||
|
description: string;
|
||||||
|
user_id: string;
|
||||||
|
location_id: string;
|
||||||
|
category_id: string;
|
||||||
|
verified: boolean;
|
||||||
}
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
export interface IEWSLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address?: string;
|
||||||
|
district?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIncidentLog {
|
||||||
|
id: string;
|
||||||
|
timestamp: Date;
|
||||||
|
location: IEWSLocation;
|
||||||
|
status: 'active' | 'resolved' | 'false_alarm';
|
||||||
|
reporter: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
response_time?: number; // in seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EWSStatus = 'idle' | 'alert' | 'responding';
|
Loading…
Reference in New Issue